Compare commits
732 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f79c11303 | ||
|
|
a9cd3db27e | ||
|
|
47d96a6ba9 | ||
|
|
3ee5e683f4 | ||
|
|
71e8cebff4 | ||
|
|
fd3f1454c5 | ||
|
|
4028739e70 | ||
|
|
067a263336 | ||
|
|
62948abf75 | ||
|
|
e8ba5c4881 | ||
|
|
e648b6dfee | ||
|
|
d1381b8700 | ||
|
|
f8df28311e | ||
|
|
59afa04744 | ||
|
|
7a5c3ae2ed | ||
|
|
9e9a5998cd | ||
|
|
f34876ca93 | ||
|
|
48752a323f | ||
|
|
ab3e6466d5 | ||
|
|
419c8fc644 | ||
|
|
69078ac42e | ||
|
|
91b6baaf1c | ||
|
|
3f3774a0cd | ||
|
|
efc40ce458 | ||
|
|
39b8bbc725 | ||
|
|
b0fedd78fb | ||
|
|
72b0fa78bb | ||
|
|
114158cf73 | ||
|
|
6d108dd7ff | ||
|
|
f36b7f1dbe | ||
|
|
0a22ebd8e9 | ||
|
|
3682eeaf94 | ||
|
|
7df2ae4ba7 | ||
|
|
c9519ec681 | ||
|
|
b146ed684d | ||
|
|
d2787c36d7 | ||
|
|
3ff663114a | ||
|
|
573e62f310 | ||
|
|
f9af670b82 | ||
|
|
bf461475c6 | ||
|
|
bdea6e0cc1 | ||
|
|
57f0ec4e5d | ||
|
|
d663092363 | ||
|
|
edf6188e36 | ||
|
|
f3f3395e68 | ||
|
|
ac9dc347e3 | ||
|
|
8721d85946 | ||
|
|
a0bd1a8738 | ||
|
|
35fdf3e3b0 | ||
|
|
aced8293f1 | ||
|
|
3f516faad8 | ||
|
|
824f7b9602 | ||
|
|
95aeeaa16f | ||
|
|
63f08f0230 | ||
|
|
3b241fe857 | ||
|
|
75bc104f43 | ||
|
|
30afd56324 | ||
|
|
5ee1bb11a0 | ||
|
|
c1de45abce | ||
|
|
8805033c8d | ||
|
|
0ed59bb8a9 | ||
|
|
8163f2fd28 | ||
|
|
521a65c9d2 | ||
|
|
eb98424668 | ||
|
|
961c731743 | ||
|
|
5188769fb6 | ||
|
|
8f27d9e30f | ||
|
|
b58566999e | ||
|
|
117d6dcd2b | ||
|
|
2608796929 | ||
|
|
792f5b5a7f | ||
|
|
a77b1db749 | ||
|
|
9d984d92af | ||
|
|
e303f25991 | ||
|
|
85973d2305 | ||
|
|
13f8d7b747 | ||
|
|
e198860edb | ||
|
|
fc8355467b | ||
|
|
67abc15442 | ||
|
|
e94cddb86a | ||
|
|
700f7a33a5 | ||
|
|
41e952144d | ||
|
|
910ed65937 | ||
|
|
e06701a2fb | ||
|
|
62dce26c73 | ||
|
|
ac0cff62d4 | ||
|
|
655c060814 | ||
|
|
36d27895e7 | ||
|
|
803481f74c | ||
|
|
b3ca1686e3 | ||
|
|
8f220eb0cb | ||
|
|
51d5f42e8b | ||
|
|
8d8c5ace61 | ||
|
|
4bb6b8ccc9 | ||
|
|
6bebd36e83 | ||
|
|
edc7053e50 | ||
|
|
55e6ef5f78 | ||
|
|
9781d7a5dc | ||
|
|
b83cf87cd8 | ||
|
|
430864512d | ||
|
|
16eeef1878 | ||
|
|
994d4b589b | ||
|
|
43adba6f13 | ||
|
|
e4fbd21731 | ||
|
|
8be64745fc | ||
|
|
b66f376729 | ||
|
|
cc40416e1e | ||
|
|
5073352366 | ||
|
|
9ae12a2c4c | ||
|
|
843b8412a9 | ||
|
|
4f67578371 | ||
|
|
37f2227093 | ||
|
|
1833c0bde5 | ||
|
|
aa3aeca3f2 | ||
|
|
152d4e248f | ||
|
|
7461c8d201 | ||
|
|
0902fdf981 | ||
|
|
0fd2cf4fd7 | ||
|
|
679558106f | ||
|
|
e498efc493 | ||
|
|
74bbc71741 | ||
|
|
502b4890e3 | ||
|
|
dfb60461e4 | ||
|
|
bd6bc418e6 | ||
|
|
a284143ce1 | ||
|
|
1f1c782772 | ||
|
|
5c0f5fe333 | ||
|
|
748e023fde | ||
|
|
30104bacd2 | ||
|
|
f33d1a1bfa | ||
|
|
3c08331441 | ||
|
|
3eaa38247b | ||
|
|
304ce643f9 | ||
|
|
b4ad994f95 | ||
|
|
03c5cfa791 | ||
|
|
e8056072b8 | ||
|
|
d134639a5f | ||
|
|
b4745d76b8 | ||
|
|
c5fd674020 | ||
|
|
9b821dd7cb | ||
|
|
1b441f6aea | ||
|
|
213902c854 | ||
|
|
2054922586 | ||
|
|
a17b7355f5 | ||
|
|
066a1e1f3a | ||
|
|
b10cbfbd63 | ||
|
|
fcd72bb8bd | ||
|
|
37cd99731c | ||
|
|
ed97773f24 | ||
|
|
0424ba3e87 | ||
|
|
9539c4e7bf | ||
|
|
248b378f01 | ||
|
|
1c40575665 | ||
|
|
ac67c648be | ||
|
|
42cc026acc | ||
|
|
23a74edfad | ||
|
|
5da1804f17 | ||
|
|
75f0c35017 | ||
|
|
0e6b02d260 | ||
|
|
d5a0ce55f0 | ||
|
|
09fc6fe8ef | ||
|
|
ff30be879a | ||
|
|
309fe4d831 | ||
|
|
dff0c817a7 | ||
|
|
04313981d4 | ||
|
|
810cb4d13a | ||
|
|
969e32e744 | ||
|
|
980909df9b | ||
|
|
e6753088a4 | ||
|
|
cbdb6cb63a | ||
|
|
3cdf1a899e | ||
|
|
c796be5de5 | ||
|
|
db301cb0c3 | ||
|
|
f00421ef23 | ||
|
|
b324654967 | ||
|
|
aa10ada3ee | ||
|
|
10c97987fb | ||
|
|
b532615bbd | ||
|
|
3066f41af3 | ||
|
|
0c401c6741 | ||
|
|
1a21d1c937 | ||
|
|
525b49a5c9 | ||
|
|
34c074bf7b | ||
|
|
b4dc961cdc | ||
|
|
93374d2cfe | ||
|
|
4009b10549 | ||
|
|
db1864205f | ||
|
|
bf39ccabbd | ||
|
|
0e8e7767ee | ||
|
|
5b6c86e34f | ||
|
|
6bbaca3686 | ||
|
|
35eae90df1 | ||
|
|
488d43e076 | ||
|
|
7c5e93c171 | ||
|
|
a20ef783e1 | ||
|
|
8ae0dce0ed | ||
|
|
44aea606b7 | ||
|
|
a05dc8c661 | ||
|
|
1f80e36017 | ||
|
|
1efca40744 | ||
|
|
86e3131afa | ||
|
|
4910b4a4b0 | ||
|
|
9c7320c0a0 | ||
|
|
02c17c3b75 | ||
|
|
49a47f4b4f | ||
|
|
68280f4a62 | ||
|
|
0e3669b247 | ||
|
|
4c9aa29d46 | ||
|
|
66fbf10f2d | ||
|
|
15ad806eb8 | ||
|
|
b7f80b9c82 | ||
|
|
9b511d2f8f | ||
|
|
6ebce2deb3 | ||
|
|
95dade13f4 | ||
|
|
ba4449d003 | ||
|
|
7632fe5e86 | ||
|
|
2c56bcacee | ||
|
|
c8202db3c6 | ||
|
|
223d689b0c | ||
|
|
4f0e7d9696 | ||
|
|
f4ce911de9 | ||
|
|
d0ad7effa0 | ||
|
|
a032beecbf | ||
|
|
46ec9e48d9 | ||
|
|
26bcef1cc0 | ||
|
|
bfb2f44f8f | ||
|
|
28b19b6774 | ||
|
|
8d72f4a3aa | ||
|
|
9c62e0399d | ||
|
|
65ea09854e | ||
|
|
9f9a4c81b3 | ||
|
|
d567b30f4b | ||
|
|
6d7c4ce0ab | ||
|
|
e062b8f9e9 | ||
|
|
08403b7a4e | ||
|
|
c6ed5d35e7 | ||
|
|
dba3460b60 | ||
|
|
f07f624fcf | ||
|
|
48ff2f328f | ||
|
|
9ae2423a40 | ||
|
|
2bc3c78c75 | ||
|
|
18e9fe75fb | ||
|
|
880a741a44 | ||
|
|
2c6ddcc64b | ||
|
|
8f2e757b77 | ||
|
|
ff177955b3 | ||
|
|
8bb8066a98 | ||
|
|
2747ddbf65 | ||
|
|
b939e9424d | ||
|
|
fb9dea5d1e | ||
|
|
da4d5d711b | ||
|
|
331cbec5f1 | ||
|
|
7f02284285 | ||
|
|
ac2c3a6d97 | ||
|
|
c3bc80fec6 | ||
|
|
09779a0710 | ||
|
|
e82c6ef866 | ||
|
|
861ae9be64 | ||
|
|
96108bc1ec | ||
|
|
016f217db0 | ||
|
|
0688294f18 | ||
|
|
9ad008255d | ||
|
|
4c5a862dd6 | ||
|
|
b165a2308f | ||
|
|
8757b08cd2 | ||
|
|
3800543fba | ||
|
|
02ef60c818 | ||
|
|
88f3b30266 | ||
|
|
9203dc0112 | ||
|
|
4c683bec68 | ||
|
|
0cfd1eb453 | ||
|
|
19744dab37 | ||
|
|
12d58e5aa7 | ||
|
|
e46dd37a26 | ||
|
|
49c3ebc36b | ||
|
|
11e9bc2235 | ||
|
|
3029b3bf0e | ||
|
|
9a6c6f67ce | ||
|
|
a6ed0baef2 | ||
|
|
d3b43d80da | ||
|
|
46d4316d49 | ||
|
|
ade2864351 | ||
|
|
365fc56e9d | ||
|
|
54a5cd21ea | ||
|
|
38c0399b09 | ||
|
|
2b67858453 | ||
|
|
87fdbdbb6e | ||
|
|
bab77a4116 | ||
|
|
d20756ab96 | ||
|
|
dc75a753c3 | ||
|
|
4712d47903 | ||
|
|
c5561801e1 | ||
|
|
5c259fa07a | ||
|
|
60e8b18702 | ||
|
|
a8317824a9 | ||
|
|
0c3c78cc72 | ||
|
|
cfd4a8faac | ||
|
|
7f3fb0db0d | ||
|
|
385d3f0d1b | ||
|
|
8fa6bd12a2 | ||
|
|
57c2004e46 | ||
|
|
c6b069bbfb | ||
|
|
c18bffd08f | ||
|
|
47ec181439 | ||
|
|
90ad40b1b7 | ||
|
|
4d3f20cf98 | ||
|
|
86df9d52bc | ||
|
|
1bd025e070 | ||
|
|
86ee239c71 | ||
|
|
27d0c01e1f | ||
|
|
7a9507be01 | ||
|
|
1490035893 | ||
|
|
a6afcb0ed0 | ||
|
|
ea7e8584cb | ||
|
|
608c6e6a1d | ||
|
|
bb2c91145f | ||
|
|
db074df0f7 | ||
|
|
f7c45df9a6 | ||
|
|
44e3d16cd6 | ||
|
|
a973cdfe0b | ||
|
|
fca42c79a8 | ||
|
|
f236775599 | ||
|
|
360decd37c | ||
|
|
998433479b | ||
|
|
c7e75aacf0 | ||
|
|
690338273a | ||
|
|
4207ea494d | ||
|
|
265473a15a | ||
|
|
b907d36770 | ||
|
|
fee280341a | ||
|
|
0f1ef70752 | ||
|
|
0f8c68b22e | ||
|
|
701017d2ca | ||
|
|
be6903ca12 | ||
|
|
1521bc1223 | ||
|
|
7ed66b827f | ||
|
|
df3a478ef3 | ||
|
|
974ddf69d5 | ||
|
|
56a91268de | ||
|
|
3dda2f9a1c | ||
|
|
ed20456f9f | ||
|
|
281d4a0023 | ||
|
|
2170403662 | ||
|
|
b1c1e96135 | ||
|
|
a8de1429c1 | ||
|
|
3ba6cb81ae | ||
|
|
acc85da80f | ||
|
|
b53de8624d | ||
|
|
6e2eeb29cc | ||
|
|
62eb28ac01 | ||
|
|
fd298529bf | ||
|
|
297ce506b1 | ||
|
|
18c6954be3 | ||
|
|
cea3fb1e65 | ||
|
|
7f274fd238 | ||
|
|
439a8e93ec | ||
|
|
83801feee9 | ||
|
|
8a6860c96e | ||
|
|
5c959f2987 | ||
|
|
4e4397287a | ||
|
|
fe02abc9e8 | ||
|
|
59347ab317 | ||
|
|
f408a91176 | ||
|
|
6f6956ce27 | ||
|
|
4ecad8eccc | ||
|
|
486fbe46a0 | ||
|
|
1ddb636dd0 | ||
|
|
081c890b4e | ||
|
|
86d528ba13 | ||
|
|
6bda3cb75a | ||
|
|
12d8949c9e | ||
|
|
ffc7c2aa67 | ||
|
|
5ec67488eb | ||
|
|
be64703d3c | ||
|
|
705925a050 | ||
|
|
29665be34d | ||
|
|
1edf986acf | ||
|
|
37be8ccf7f | ||
|
|
ead68b5201 | ||
|
|
4409664698 | ||
|
|
fc6bc7965c | ||
|
|
f70eccb1da | ||
|
|
861994e804 | ||
|
|
2b8facfb97 | ||
|
|
9583897ada | ||
|
|
7704c96955 | ||
|
|
c96d609803 | ||
|
|
aa0e5000ab | ||
|
|
7ca4418a50 | ||
|
|
fdd9b02388 | ||
|
|
ece127e982 | ||
|
|
5488e14f32 | ||
|
|
3558d826fb | ||
|
|
68c94d1d8b | ||
|
|
1a4ae5dfc6 | ||
|
|
1a95afe266 | ||
|
|
6579db3cc8 | ||
|
|
ceac01533a | ||
|
|
216914882c | ||
|
|
735dbab695 | ||
|
|
dbaab152ef | ||
|
|
9da1b30984 | ||
|
|
9415ab4ef9 | ||
|
|
647294daf2 | ||
|
|
6ebc386474 | ||
|
|
3e657bdc09 | ||
|
|
0b0adb76a1 | ||
|
|
17b3e010aa | ||
|
|
20003acd73 | ||
|
|
2ab7672092 | ||
|
|
c317abe64b | ||
|
|
bc33ce1ebc | ||
|
|
684c5cf38b | ||
|
|
c34e15f0a1 | ||
|
|
bad004f892 | ||
|
|
828d3de020 | ||
|
|
132b3b9be1 | ||
|
|
388bc6fda5 | ||
|
|
a93edc184d | ||
|
|
08672d10ac | ||
|
|
b563dae3a8 | ||
|
|
917f9672dd | ||
|
|
9ddb19530b | ||
|
|
431e56a9f1 | ||
|
|
71093aac4c | ||
|
|
47c9e8127e | ||
|
|
24b801b346 | ||
|
|
70608c3ed9 | ||
|
|
f185196e85 | ||
|
|
a8766a8bbe | ||
|
|
27a8c93cfe | ||
|
|
a3cd29fda9 | ||
|
|
adda8ab640 | ||
|
|
1538ea5fc8 | ||
|
|
2367a97a54 | ||
|
|
090ec0e4af | ||
|
|
de7f552e5c | ||
|
|
d763f5dca0 | ||
|
|
9f41116241 | ||
|
|
57faada201 | ||
|
|
1edb95f0c5 | ||
|
|
9f363d8900 | ||
|
|
0bf2f1b6e1 | ||
|
|
68c7a38390 | ||
|
|
841c8a7a15 | ||
|
|
6c9688183b | ||
|
|
ccd84c91f6 | ||
|
|
318d6f9b52 | ||
|
|
8f5d612ee0 | ||
|
|
56b2a05596 | ||
|
|
4db0022d6a | ||
|
|
67f37d3188 | ||
|
|
ed81cc7207 | ||
|
|
065845f1be | ||
|
|
902f705e89 | ||
|
|
ec2e0ef773 | ||
|
|
d28c5741d0 | ||
|
|
e6e3f9e8f8 | ||
|
|
90e1dc59bd | ||
|
|
0b1c9b097c | ||
|
|
2b553d1116 | ||
|
|
567eec8bc5 | ||
|
|
293ca5b31d | ||
|
|
0d0f2bd827 | ||
|
|
5bc4610061 | ||
|
|
e6b7c107f2 | ||
|
|
51a9bf2570 | ||
|
|
8385f6f390 | ||
|
|
772e9daf57 | ||
|
|
8adc4405c5 | ||
|
|
349da7aa81 | ||
|
|
01a01d481d | ||
|
|
2f8445fb83 | ||
|
|
b04a5fc150 | ||
|
|
bbe29941df | ||
|
|
2720e445ea | ||
|
|
49ba579a59 | ||
|
|
3198c6cbfd | ||
|
|
b3feee6d9d | ||
|
|
f0f53e6bce | ||
|
|
24486d13f2 | ||
|
|
20bc9461de | ||
|
|
c8e94cc295 | ||
|
|
b2bfb0c237 | ||
|
|
0a003da724 | ||
|
|
b4f2a33016 | ||
|
|
ee7ede2885 | ||
|
|
6abc404eb7 | ||
|
|
61afe01e36 | ||
|
|
c3e60f9988 | ||
|
|
593197cd7e | ||
|
|
ee1592b478 | ||
|
|
dfe435c4f3 | ||
|
|
69e85f8b90 | ||
|
|
c9bde3c487 | ||
|
|
65e9557d9f | ||
|
|
4f249c07e7 | ||
|
|
5fd35b492c | ||
|
|
9bddf95013 | ||
|
|
03444f070f | ||
|
|
2f1a63eb64 | ||
|
|
9d0898b26c | ||
|
|
994aa99797 | ||
|
|
8204a15276 | ||
|
|
4a8bff0b98 | ||
|
|
a4336cd954 | ||
|
|
4f0dbead79 | ||
|
|
c0e7c87ca4 | ||
|
|
b967bf9a26 | ||
|
|
764a265053 | ||
|
|
68c2b2dbfa | ||
|
|
061f1263f4 | ||
|
|
2a27355479 | ||
|
|
ae2a8e8ada | ||
|
|
68dcc2333b | ||
|
|
66fb2e9a62 | ||
|
|
1dbfc64f37 | ||
|
|
98d1f88579 | ||
|
|
bb6fadc182 | ||
|
|
ac1ca71299 | ||
|
|
0d93785581 | ||
|
|
69a9d63e1d | ||
|
|
5dea35343b | ||
|
|
5c768d2121 | ||
|
|
4d5834821a | ||
|
|
ca077c4fee | ||
|
|
85d01f60f1 | ||
|
|
066d73b217 | ||
|
|
ba069d8f8e | ||
|
|
275684c9ce | ||
|
|
49d87a08d2 | ||
|
|
04c500f3d8 | ||
|
|
d05c1e4d08 | ||
|
|
bb63959678 | ||
|
|
842148647f | ||
|
|
19308d840a | ||
|
|
46bd1318cd | ||
|
|
9d1998fe52 | ||
|
|
a714a8230b | ||
|
|
b5432cd0b4 | ||
|
|
5634e94f3e | ||
|
|
c1a71b0db3 | ||
|
|
d93e7f8834 | ||
|
|
3175b2c45c | ||
|
|
547b6e8e3b | ||
|
|
d88ac27e72 | ||
|
|
e551a40d08 | ||
|
|
e810abe33a | ||
|
|
6172a73719 | ||
|
|
7455e68a45 | ||
|
|
748495ca64 | ||
|
|
f6d9c7f550 | ||
|
|
384e6c61b0 | ||
|
|
d49c9cec20 | ||
|
|
4b27f1aba1 | ||
|
|
a0a989c785 | ||
|
|
ecaecc1b91 | ||
|
|
938156aa71 | ||
|
|
d30c51bb3a | ||
|
|
874606bff9 | ||
|
|
07643e4b4c | ||
|
|
20bc5423cf | ||
|
|
b84cddffdc | ||
|
|
e46d1123df | ||
|
|
48f90faf4e | ||
|
|
615b52c4fa | ||
|
|
2c9c8e223c | ||
|
|
01a653835e | ||
|
|
9d80857a38 | ||
|
|
8a9ab6b36c | ||
|
|
4edc87c197 | ||
|
|
10712e6e62 | ||
|
|
d73dc19d3d | ||
|
|
c204353220 | ||
|
|
37123a2cd5 | ||
|
|
a39484b6ea | ||
|
|
e81b5a4e3a | ||
|
|
0b87c57fbf | ||
|
|
5fd985ba39 | ||
|
|
8c64548513 | ||
|
|
a6de64ceb9 | ||
|
|
16ebb437a3 | ||
|
|
683118a3f4 | ||
|
|
08e38ed45c | ||
|
|
7abf08f1fb | ||
|
|
f3019e9b84 | ||
|
|
9ea55664b6 | ||
|
|
c468764234 | ||
|
|
31c3178430 | ||
|
|
e81c189afc | ||
|
|
e0ccac13c1 | ||
|
|
93228459d7 | ||
|
|
63e07f56e0 | ||
|
|
ee87122bb2 | ||
|
|
290dda9018 | ||
|
|
1d3d78b936 | ||
|
|
a947bc6415 | ||
|
|
9ca891b2f5 | ||
|
|
48e0ebc8ae | ||
|
|
b323353006 | ||
|
|
c85d3ebe81 | ||
|
|
ce843abec8 | ||
|
|
6b43faa70e | ||
|
|
2d0c997b2e | ||
|
|
1db5118377 | ||
|
|
26b53ed7ac | ||
|
|
2c85ea6443 | ||
|
|
cbc2b30f47 | ||
|
|
0b58deb92c | ||
|
|
ed1cf23c91 | ||
|
|
6fbb644e4b | ||
|
|
774867502d | ||
|
|
c8b1439aeb | ||
|
|
38c16adffe | ||
|
|
18aede2701 | ||
|
|
c59d08a0a1 | ||
|
|
66ae29eb5b | ||
|
|
7d9cb3e150 | ||
|
|
9922a9f82a | ||
|
|
445b9b4673 | ||
|
|
0ef7b358e0 | ||
|
|
2d3fb75576 | ||
|
|
d55ff6d68e | ||
|
|
079654a9c7 | ||
|
|
30263c6260 | ||
|
|
3159c343c1 | ||
|
|
ceaa930623 | ||
|
|
6a8539106b | ||
|
|
7a24c3c08e | ||
|
|
251abeb090 | ||
|
|
a61fe9f98c | ||
|
|
d29c7bf91a | ||
|
|
ed4911c441 | ||
|
|
d40b4f3748 | ||
|
|
f3c4fe1914 | ||
|
|
55ee841bd0 | ||
|
|
657fb488ee | ||
|
|
4eef0b93fb | ||
|
|
f2be56435c | ||
|
|
fa6b3ad7ba | ||
|
|
52c05e6888 | ||
|
|
865bf0ba83 | ||
|
|
3f827d1bad | ||
|
|
0561d5f55c | ||
|
|
1bf2e1dacc | ||
|
|
db5a221b56 | ||
|
|
295285f132 | ||
|
|
5052b6c074 | ||
|
|
f98f45dc54 | ||
|
|
8d16950f46 | ||
|
|
74033b9f4a | ||
|
|
e497d47374 | ||
|
|
a97af59260 | ||
|
|
2197de98ea | ||
|
|
c004c7f71a | ||
|
|
69fc3ad4e8 | ||
|
|
678a8f0914 | ||
|
|
08c4c0bf1f | ||
|
|
f2a2656837 | ||
|
|
2011572270 | ||
|
|
3b682667e1 | ||
|
|
6da8de6463 | ||
|
|
039d415871 | ||
|
|
776f53bde0 | ||
|
|
58e535595e | ||
|
|
96ad5f6a6c | ||
|
|
043f7bedd8 | ||
|
|
69bcd8f7c0 | ||
|
|
8a58564812 | ||
|
|
d346cf431f | ||
|
|
c0bce4f3b1 | ||
|
|
94d258ddbb | ||
|
|
6bdba49284 | ||
|
|
9b99baf4bc | ||
|
|
5ad2a538bc | ||
|
|
28703e9bf2 | ||
|
|
e664efefe9 | ||
|
|
27a8694938 | ||
|
|
e0a6102d4d | ||
|
|
7106cf04ed | ||
|
|
2afdc5591a | ||
|
|
8eed4b67c3 | ||
|
|
edacef0f2b | ||
|
|
d28894f8cd | ||
|
|
ee8e921e1a | ||
|
|
480d4b1e9a | ||
|
|
a79c023220 | ||
|
|
efc50df243 | ||
|
|
905ea766b1 | ||
|
|
bce26f4557 | ||
|
|
474d3ad80a | ||
|
|
a74b2c9b49 | ||
|
|
22bdf61bb3 | ||
|
|
69f9b099b7 | ||
|
|
1d812487a6 | ||
|
|
7c2bf8fb9d | ||
|
|
dfb78bed69 | ||
|
|
fb42b48880 | ||
|
|
bb0988a188 | ||
|
|
c64b6f112b | ||
|
|
9ac7fb490e | ||
|
|
bd88a8a8d3 | ||
|
|
1eb75acb40 | ||
|
|
5ccc96aeb9 | ||
|
|
ef72d10344 | ||
|
|
573f0b40d1 | ||
|
|
48f49edb19 | ||
|
|
aa22d9fdd8 | ||
|
|
8410a2fdb3 | ||
|
|
ec98e4e9a4 | ||
|
|
dca6ba457b | ||
|
|
5b10a781a6 | ||
|
|
b103188faf | ||
|
|
29637b234c | ||
|
|
34dc238ef1 | ||
|
|
3c2675e650 | ||
|
|
7e87bb6838 | ||
|
|
3992a07340 | ||
|
|
bd4b61d7ac | ||
|
|
2046d87031 | ||
|
|
0618d8c6f8 | ||
|
|
5bfc27835b | ||
|
|
cdc545ea32 | ||
|
|
449db97a2b | ||
|
|
e01380090d | ||
|
|
6d1505241e | ||
|
|
f303e49e97 | ||
|
|
0e6b50e302 | ||
|
|
868af1e6a2 | ||
|
|
34f7b111ee | ||
|
|
df27907c57 | ||
|
|
75583b9e65 |
47
.gitignore
vendored
@@ -1,16 +1,33 @@
|
|||||||
*.iml
|
# Gradle files
|
||||||
.gradle
|
.gradle/
|
||||||
/local.properties
|
build/
|
||||||
/.idea/caches
|
|
||||||
/.idea/libraries
|
|
||||||
/.idea/modules.xml
|
|
||||||
/.idea/workspace.xml
|
|
||||||
/.idea/navEditor.xml
|
|
||||||
/.idea/assetWizardSettings.xml
|
|
||||||
.DS_Store
|
|
||||||
/build
|
|
||||||
/captures
|
|
||||||
.externalNativeBuild
|
|
||||||
|
|
||||||
#Github pages
|
# Local configuration file (sdk path, etc)
|
||||||
/gh-pages
|
local.properties
|
||||||
|
|
||||||
|
# Log/OS Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio generated files and folders
|
||||||
|
captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
*.apk
|
||||||
|
output.json
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
misc.xml
|
||||||
|
deploymentTargetDropDown.xml
|
||||||
|
render.experimental.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
google-services.json
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|||||||
122
.idea/codeStyles/Project.xml
generated
@@ -1,122 +0,0 @@
|
|||||||
<component name="ProjectCodeStyleConfiguration">
|
|
||||||
<code_scheme name="Project" version="173">
|
|
||||||
<AndroidXmlCodeStyleSettings>
|
|
||||||
<option name="USE_CUSTOM_SETTINGS" value="true" />
|
|
||||||
</AndroidXmlCodeStyleSettings>
|
|
||||||
<JetCodeStyleSettings>
|
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
|
||||||
</JetCodeStyleSettings>
|
|
||||||
<XML>
|
|
||||||
<option name="XML_KEEP_LINE_BREAKS" value="false" />
|
|
||||||
<option name="XML_ALIGN_ATTRIBUTES" value="false" />
|
|
||||||
<option name="XML_SPACE_INSIDE_EMPTY_TAG" value="true" />
|
|
||||||
</XML>
|
|
||||||
<codeStyleSettings language="XML">
|
|
||||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
|
||||||
<indentOptions>
|
|
||||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
|
||||||
</indentOptions>
|
|
||||||
<arrangement>
|
|
||||||
<rules>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>xmlns:android</NAME>
|
|
||||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>xmlns:.*</NAME>
|
|
||||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
<order>BY_NAME</order>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>.*:id</NAME>
|
|
||||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>.*:name</NAME>
|
|
||||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>name</NAME>
|
|
||||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>style</NAME>
|
|
||||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>.*</NAME>
|
|
||||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
<order>BY_NAME</order>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>.*</NAME>
|
|
||||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<rule>
|
|
||||||
<match>
|
|
||||||
<AND>
|
|
||||||
<NAME>.*</NAME>
|
|
||||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
|
||||||
</AND>
|
|
||||||
</match>
|
|
||||||
<order>BY_NAME</order>
|
|
||||||
</rule>
|
|
||||||
</section>
|
|
||||||
</rules>
|
|
||||||
</arrangement>
|
|
||||||
</codeStyleSettings>
|
|
||||||
<codeStyleSettings language="kotlin">
|
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
|
||||||
</codeStyleSettings>
|
|
||||||
</code_scheme>
|
|
||||||
</component>
|
|
||||||
5
.idea/codeStyles/codeStyleConfig.xml
generated
@@ -1,5 +0,0 @@
|
|||||||
<component name="ProjectCodeStyleConfiguration">
|
|
||||||
<state>
|
|
||||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
|
||||||
</state>
|
|
||||||
</component>
|
|
||||||
4
.idea/encodings.xml
generated
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Encoding" addBOMForNewFiles="with NO BOM" />
|
|
||||||
</project>
|
|
||||||
20
.idea/gradle.xml
generated
@@ -1,20 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="GradleSettings">
|
|
||||||
<option name="linkedExternalProjectsSettings">
|
|
||||||
<GradleProjectSettings>
|
|
||||||
<option name="distributionType" value="DEFAULT_WRAPPED" />
|
|
||||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
|
||||||
<option name="modules">
|
|
||||||
<set>
|
|
||||||
<option value="$PROJECT_DIR$" />
|
|
||||||
<option value="$PROJECT_DIR$/app" />
|
|
||||||
<option value="$PROJECT_DIR$/libpupil" />
|
|
||||||
</set>
|
|
||||||
</option>
|
|
||||||
<option name="resolveModulePerSourceSet" value="false" />
|
|
||||||
<option name="useQualifiedModuleNames" value="true" />
|
|
||||||
</GradleProjectSettings>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
10
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,10 +0,0 @@
|
|||||||
<component name="InspectionProjectProfileManager">
|
|
||||||
<profile version="1.0">
|
|
||||||
<option name="myName" value="Project Default" />
|
|
||||||
<inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
|
|
||||||
<option name="processCode" value="true" />
|
|
||||||
<option name="processLiterals" value="true" />
|
|
||||||
<option name="processComments" value="true" />
|
|
||||||
</inspection_tool>
|
|
||||||
</profile>
|
|
||||||
</component>
|
|
||||||
6
.idea/misc.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK">
|
|
||||||
<output url="file://$PROJECT_DIR$/classes" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
12
.idea/runConfigurations.xml
generated
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="RunConfigurationProducerService">
|
|
||||||
<option name="ignoredProducers">
|
|
||||||
<set>
|
|
||||||
<option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
|
|
||||||
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
|
|
||||||
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />
|
|
||||||
</set>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
2
LICENSE
@@ -1,4 +1,4 @@
|
|||||||
License is specified in following module separately
|
License is specified in following module separately
|
||||||
|
|
||||||
app/
|
app/
|
||||||
libpupil/
|
libpupil/
|
||||||
28
README.md
@@ -1,2 +1,26 @@
|
|||||||
# Pupil
|

|
||||||
Hitomi.la viewer for Android
|
*Pupil, Hitomi.la viewer for Android*
|
||||||
|
|
||||||
|

|
||||||
|
[](https://github.com/tom5079/Pupil/releases/download/5.3.8-hotfix1/Pupil-v5.3.8-hotfix1.apk)
|
||||||
|
[](https://discord.gg/Stj4b5v)
|
||||||
|
|
||||||
|
# Features
|
||||||
|

|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
Go [Releases page](https://github.com/tom5079/Pupil/releases) and get latest version or
|
||||||
|
Visit [github page](https://tom5079.github.io/Pupil/) (only available in Korean)
|
||||||
|
or Build app yourself
|
||||||
|
|
||||||
|
# Manual
|
||||||
|
|
||||||
|
[Manual](https://tom5079.github.io/Pupil/2019/06/06/manual-kr.html) is only available in Korean. Consider using translator.
|
||||||
|
|
||||||
|
# Contribution
|
||||||
|
|
||||||
|
Any kind of contribution is appreciated. Feel free to leave PR!
|
||||||
|
|
||||||
|
## Tag Translation
|
||||||
|
Head over to [tags branch](https://github.com/tom5079/Pupil/tree/tags)
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
apply plugin: 'com.android.application'
|
|
||||||
apply plugin: 'kotlin-android'
|
|
||||||
apply plugin: 'kotlin-android-extensions'
|
|
||||||
apply plugin: 'kotlinx-serialization'
|
|
||||||
apply plugin: 'com.google.gms.google-services'
|
|
||||||
apply plugin: 'io.fabric'
|
|
||||||
apply plugin: 'com.google.firebase.firebase-perf'
|
|
||||||
|
|
||||||
android {
|
|
||||||
compileSdkVersion 29
|
|
||||||
defaultConfig {
|
|
||||||
applicationId "xyz.quaver.pupil"
|
|
||||||
minSdkVersion 16
|
|
||||||
targetSdkVersion 29
|
|
||||||
versionCode 20
|
|
||||||
versionName "2.11.1"
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
multiDexEnabled true
|
|
||||||
vectorDrawables.useSupportLibrary = true
|
|
||||||
}
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
minifyEnabled false
|
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
freeCompilerArgs += '-Xuse-experimental=kotlin.Experimental'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
def markwonVersion = "3.0.1"
|
|
||||||
|
|
||||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
|
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.1'
|
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1'
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.11.0"
|
|
||||||
implementation 'androidx.appcompat:appcompat:1.0.2'
|
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
|
||||||
implementation 'androidx.preference:preference:1.1.0-beta01'
|
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
|
|
||||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.0.0'
|
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.0.0'
|
|
||||||
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
|
||||||
implementation 'com.android.support:multidex:1.0.3'
|
|
||||||
implementation 'com.google.android.material:material:1.0.0'
|
|
||||||
implementation 'com.google.firebase:firebase-core:17.0.0'
|
|
||||||
implementation 'com.google.firebase:firebase-perf:18.0.1'
|
|
||||||
implementation 'com.crashlytics.sdk.android:crashlytics:2.10.1'
|
|
||||||
implementation 'com.github.arimorty:floatingsearchview:2.1.1'
|
|
||||||
implementation 'com.github.clans:fab:1.6.4'
|
|
||||||
implementation 'com.andrognito.patternlockview:patternlockview:1.0.0'
|
|
||||||
implementation "ru.noties.markwon:core:${markwonVersion}"
|
|
||||||
testImplementation 'junit:junit:4.12'
|
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
|
|
||||||
androidTestImplementation 'androidx.test:rules:1.2.0'
|
|
||||||
androidTestImplementation 'androidx.test:runner:1.2.0'
|
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
|
|
||||||
implementation project(path: ':libpupil')
|
|
||||||
}
|
|
||||||
|
|
||||||
androidExtensions {
|
|
||||||
experimental = true
|
|
||||||
}
|
|
||||||
95
app/build.gradle.kts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.org.jetbrains.kotlin.android)
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.compose.compiler)
|
||||||
|
alias(libs.plugins.googleServices)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.hilt)
|
||||||
|
alias(libs.plugins.kotlinx.serialization)
|
||||||
|
alias(libs.plugins.crashlytics)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "xyz.quaver.pupil"
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "xyz.quaver.pupil"
|
||||||
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
|
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||||
|
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||||
|
versionCode = 69
|
||||||
|
versionName = "6.0.0"
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
getByName("debug") {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
isShrinkResources = false
|
||||||
|
isDebuggable = true
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-DEBUG"
|
||||||
|
ext.set("enableCrashlytics", false)
|
||||||
|
ext.set("alwaysUpdateBuildId", false)
|
||||||
|
}
|
||||||
|
getByName("release") {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
coreLibraryDesugaring(libs.android.desugaring)
|
||||||
|
|
||||||
|
implementation(libs.kotlinx.serialization)
|
||||||
|
implementation(libs.kotlinx.coroutines)
|
||||||
|
implementation(libs.kotlinx.datetime)
|
||||||
|
|
||||||
|
implementation(libs.androidx.core)
|
||||||
|
implementation(libs.androidx.activity)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material3.windowSizeClass)
|
||||||
|
implementation(libs.androidx.compose.foundation)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.compose.material.icons.extended)
|
||||||
|
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
|
implementation(libs.accompanist.adaptive)
|
||||||
|
|
||||||
|
implementation(libs.coil)
|
||||||
|
|
||||||
|
implementation(platform(libs.firebase.bom))
|
||||||
|
implementation(libs.firebase.analytics)
|
||||||
|
implementation(libs.firebase.crashlytics)
|
||||||
|
implementation(libs.firebase.perf)
|
||||||
|
|
||||||
|
implementation(libs.hilt.android)
|
||||||
|
ksp(libs.hilt.compiler)
|
||||||
|
|
||||||
|
implementation(libs.ktor.client)
|
||||||
|
implementation(libs.ktor.client.okhttp)
|
||||||
|
|
||||||
|
implementation(libs.documentFileX)
|
||||||
|
}
|
||||||
BIN
app/libs/pinlockview-release.aar
Normal file
BIN
app/libs/recyclerviewfastscroller-release.aar
Normal file
18
app/proguard-rules.pro
vendored
@@ -1,6 +1,6 @@
|
|||||||
# Add project specific ProGuard rules here.
|
# Add project specific ProGuard rules here.
|
||||||
# You can control the set of applied configuration files using the
|
# You can control the set of applied configuration files using the
|
||||||
# proguardFiles setting in build.gradle.
|
# proguardFiles setting in build.gradle.kts.
|
||||||
#
|
#
|
||||||
# For more details, see
|
# For more details, see
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
@@ -18,4 +18,18 @@
|
|||||||
|
|
||||||
# If you keep the line number information, uncomment this to
|
# If you keep the line number information, uncomment this to
|
||||||
# hide the original source file name.
|
# hide the original source file name.
|
||||||
#-renamesourcefileattribute SourceFile
|
#-renamesourcefileattribute SourceFile
|
||||||
|
|
||||||
|
-dontobfuscate
|
||||||
|
|
||||||
|
-keepattributes *Annotation*, InnerClasses
|
||||||
|
-dontnote kotlinx.serialization.SerializationKt
|
||||||
|
-keep,includedescriptorclasses class xyz.quaver.**$$serializer { *; } # <-- change package name to your app's
|
||||||
|
-keepclassmembers class xyz.quaver.pupil.** { # <-- change package name to your app's
|
||||||
|
*** Companion;
|
||||||
|
}
|
||||||
|
-keepclasseswithmembers class xyz.quaver.pupil.** { # <-- change package name to your app's
|
||||||
|
kotlinx.serialization.KSerializer serializer(...);
|
||||||
|
}
|
||||||
|
-keep class xyz.quaver.pupil.** { *; }
|
||||||
|
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||||
20
app/release/output-metadata.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": 3,
|
||||||
|
"artifactType": {
|
||||||
|
"type": "APK",
|
||||||
|
"kind": "Directory"
|
||||||
|
},
|
||||||
|
"applicationId": "xyz.quaver.pupil",
|
||||||
|
"variantName": "release",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "SINGLE",
|
||||||
|
"filters": [],
|
||||||
|
"attributes": [],
|
||||||
|
"versionCode": 69,
|
||||||
|
"versionName": "6.0.0",
|
||||||
|
"outputFile": "app-release.apk"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"elementType": "File"
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
[{"outputType":{"type":"APK"},"apkData":{"type":"MAIN","splits":[],"versionCode":20,"versionName":"2.11.1","enabled":true,"outputFile":"app-release.apk","fullName":"release","baseName":"release"},"path":"app-release.apk","properties":{}}]
|
|
||||||
@@ -1,21 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2019 tom5079
|
||||||
|
*
|
||||||
|
* This program 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.
|
||||||
|
*
|
||||||
|
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:Suppress("UNUSED_VARIABLE")
|
||||||
|
|
||||||
package xyz.quaver.pupil
|
package xyz.quaver.pupil
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import kotlinx.coroutines.runBlocking
|
||||||
import androidx.test.rule.ActivityTestRule
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Rule
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import xyz.quaver.hitomi.fetchNozomi
|
import xyz.quaver.pupil.hitomi.*
|
||||||
import xyz.quaver.hiyobi.cookie
|
import java.util.*
|
||||||
import xyz.quaver.hiyobi.getReader
|
import java.util.concurrent.TimeUnit
|
||||||
import xyz.quaver.hiyobi.user_agent
|
|
||||||
import xyz.quaver.pupil.ui.LockActivity
|
|
||||||
import java.net.URL
|
|
||||||
import javax.net.ssl.HttpsURLConnection
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instrumented test, which will execute on an Android device.
|
* Instrumented test, which will execute on an Android device.
|
||||||
@@ -24,39 +40,144 @@ import javax.net.ssl.HttpsURLConnection
|
|||||||
*/
|
*/
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class ExampleInstrumentedTest {
|
class ExampleInstrumentedTest {
|
||||||
|
// @Before
|
||||||
|
// fun init() {
|
||||||
|
// val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
// }
|
||||||
|
|
||||||
@Test
|
@Before
|
||||||
fun useAppContext() {
|
fun init() {
|
||||||
// Context of the app under test.
|
clientBuilder = OkHttpClient.Builder()
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
.readTimeout(0, TimeUnit.SECONDS)
|
||||||
assertEquals("xyz.quaver.pupil", appContext.packageName)
|
.writeTimeout(0, TimeUnit.SECONDS)
|
||||||
|
.callTimeout(0, TimeUnit.SECONDS)
|
||||||
|
.connectTimeout(0, TimeUnit.SECONDS)
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
val request = chain.request().newBuilder()
|
||||||
|
.header("Referer", "https://hitomi.la/")
|
||||||
|
.build()
|
||||||
|
|
||||||
Log.d("Pupil", fetchNozomi().first.size.toString())
|
chain.proceed(request)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun checkCacheDir() {
|
fun test_empty() {
|
||||||
val activityTestRule = ActivityTestRule<LockActivity>(LockActivity::class.java)
|
print(
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
"".trim()
|
||||||
|
.replace(Regex("""^\?"""), "")
|
||||||
|
.lowercase(Locale.getDefault())
|
||||||
|
.split(Regex("\\s+"))
|
||||||
|
.map {
|
||||||
|
it.replace('_', ' ')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
@Test
|
||||||
|
fun test_nozomi() {
|
||||||
|
val nozomi = getGalleryIDsFromNozomi(null, "index", "all")
|
||||||
|
|
||||||
activityTestRule.launchActivity(Intent())
|
Log.d("PUPILD", nozomi.size.toString())
|
||||||
|
}
|
||||||
|
|
||||||
while(true);
|
@Test
|
||||||
|
fun test_search() {
|
||||||
|
val ids = getGalleryIDsForQuery("language:korean").reversed()
|
||||||
|
|
||||||
|
print(ids.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_suggestions() {
|
||||||
|
val suggestions = getSuggestionsForQuery("language:g")
|
||||||
|
|
||||||
|
print(suggestions)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun test_doSearch() {
|
fun test_doSearch() {
|
||||||
val reader = getReader(1426382)
|
val r = runBlocking {
|
||||||
|
doSearch("language:korean")
|
||||||
val data: ByteArray
|
|
||||||
|
|
||||||
with(URL(reader[0].url).openConnection() as HttpsURLConnection) {
|
|
||||||
setRequestProperty("User-Agent", user_agent)
|
|
||||||
setRequestProperty("Cookie", cookie)
|
|
||||||
|
|
||||||
data = inputStream.readBytes()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d("Pupil", data.size.toString())
|
Log.d("PUPILD", r.take(10).toString())
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// @Test
|
||||||
|
// fun test_getBlock() {
|
||||||
|
// val galleryBlock = getGalleryBlock(2097576)
|
||||||
|
//
|
||||||
|
// print(galleryBlock)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// @Test
|
||||||
|
// fun test_getGallery() {
|
||||||
|
// val gallery = getGallery(2097751)
|
||||||
|
//
|
||||||
|
// print(gallery)
|
||||||
|
// }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_getGalleryInfo() {
|
||||||
|
val info = getGalleryInfo(1469394)
|
||||||
|
|
||||||
|
print(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_getReader() {
|
||||||
|
val reader = getGalleryInfo(2128654)
|
||||||
|
|
||||||
|
Log.d("PUPILD", reader.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test_getImages() { runBlocking {
|
||||||
|
val galleryID = 2128654
|
||||||
|
|
||||||
|
val images = getGalleryInfo(galleryID).files.map {
|
||||||
|
imageUrlFromImage(galleryID, it,false)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d("PUPILD", images.toString())
|
||||||
|
|
||||||
|
// images.forEachIndexed { index, image ->
|
||||||
|
// println("Testing $index/${images.size}: $image")
|
||||||
|
// val response = client.newCall(
|
||||||
|
// Request.Builder()
|
||||||
|
// .url(image)
|
||||||
|
// .header("Referer", "https://hitomi.la/")
|
||||||
|
// .build()
|
||||||
|
// ).execute()
|
||||||
|
//
|
||||||
|
// assertEquals(200, response.code())
|
||||||
|
//
|
||||||
|
// println("$index/${images.size} Passed")
|
||||||
|
// }
|
||||||
|
} }
|
||||||
|
|
||||||
|
// @Test
|
||||||
|
// fun test_urlFromUrlFromHash() {
|
||||||
|
// val url = urlFromUrlFromHash(1531795, GalleryFiles(
|
||||||
|
// 212, "719d46a7556be0d0021c5105878507129b5b3308b02cf67f18901b69dbb3b5ef", 1, "00.jpg", 300
|
||||||
|
// ), "webp")
|
||||||
|
//
|
||||||
|
// print(url)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Test
|
||||||
|
// suspend fun test_doSearch_extreme() {
|
||||||
|
// val query = "language:korean -tag:sample -female:humiliation -female:diaper -female:strap-on -female:squirting -female:lizard_girl -female:voyeurism -type:artistcg -female:blood -female:ryona -male:blood -male:ryona -female:crotch_tattoo -male:urethra_insertion -female:living_clothes -male:tentacles -female:slave -female:gag -male:gag -female:wooden_horse -male:exhibitionism -male:miniguy -female:mind_break -male:mind_break -male:unbirth -tag:scanmark -tag:no_penetration -tag:nudity_only -female:enema -female:brain_fuck -female:navel_fuck -tag:novel -tag:mosaic_censorship -tag:webtoon -male:rape -female:rape -female:yuri -male:anal -female:anal -female:futanari -female:huge_breasts -female:big_areolae -male:torture -male:stuck_in_wall -female:stuck_in_wall -female:torture -female:birth -female:pregnant -female:drugs -female:bdsm -female:body_writing -female:cbt -male:dark_skin -male:insect -female:insect -male:vore -female:vore -female:vomit -female:urination -female:urethra_insertion -tag:mmf_threesome -female:sex_toys -female:double_penetration -female:eggs -female:prolapse -male:smell -male:bestiality -female:bestiality -female:big_ass -female:milf -female:mother -male:dilf -male:netorare -female:netorare -female:cosplaying -female:filming -female:armpit_sex -female:armpit_licking -female:tickling -female:lactation -male:skinsuit -female:skinsuit -male:bbm -female:prostitution -female:double_penetration -female:females_only -male:males_only -female:tentacles -female:tentacles -female:stomach_deformation -female:hairy_armpits -female:large_insertions -female:mind_control -male:orc -female:dark_skin -male:yandere -female:yandere -female:scat -female:toddlercon -female:bbw -female:hairy -male:cuntboy -male:lactation -male:drugs -female:body_modification -female:monoeye -female:chikan -female:long_tongue -female:harness -female:fisting -female:glory_hole -female:latex -male:latex -female:unbirth -female:giantess -female:sole_dickgirl -female:robot -female:doll_joints -female:machine -tag:artbook -male:cbt -female:farting -male:farting -male:midget -female:midget -female:exhibitionism -male:monster -female:big_nipples -female:big_clit -female:gyaru -female:piercing -female:necrophilia -female:snuff -female:smell -male:cheating -female:cheating -male:snuff -female:harem -male:harem"
|
||||||
|
// print(doSearch(query).size)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Test
|
||||||
|
// suspend fun test_parse() {
|
||||||
|
// print(doSearch("-male:yaoi -female:yaoi -female:loli").size)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Test
|
||||||
|
// fun test_subdomainFromUrl() {
|
||||||
|
// val galleryInfo = getGalleryInfo(1929109).files[2]
|
||||||
|
// print(urlFromUrlFromHash(1929109, galleryInfo, "webp", null, "a"))
|
||||||
|
// }
|
||||||
|
}
|
||||||
22
app/src/debug/res/values/strings.xml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
~ Pupil, Hitomi.la viewer for Android
|
||||||
|
~ Copyright (C) 2020 tom5079
|
||||||
|
~
|
||||||
|
~ This program 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.
|
||||||
|
~
|
||||||
|
~ This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<string name="app_name" translatable="false" tools:override="true">Pupil-Debug</string>
|
||||||
|
</resources>
|
||||||
@@ -1,9 +1,20 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="xyz.quaver.pupil">
|
xmlns:tools="http://schemas.android.com/tools" >
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="23"/>
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="23"/>
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
<uses-permission android:name="android.permission.RUN_USER_INITIATED_JOBS" />
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".Pupil"
|
android:name=".Pupil"
|
||||||
@@ -13,108 +24,35 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme">
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
<activity android:name=".ui.LockActivity"/>
|
tools:ignore="UnusedAttribute" >
|
||||||
<activity
|
|
||||||
android:name=".ui.ReaderActivity"
|
|
||||||
android:configChanges="keyboardHidden|orientation|screenSize"
|
|
||||||
android:parentActivityName=".ui.MainActivity">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<meta-data
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||||
|
android:value="face" />
|
||||||
|
|
||||||
<data
|
<provider
|
||||||
android:host="hitomi.la"
|
android:authorities="${applicationId}.provider"
|
||||||
android:pathPrefix="/galleries"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:scheme="https" />
|
android:exported="false"
|
||||||
</intent-filter>
|
android:grantUriPermissions="true">
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<meta-data
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
|
||||||
<data
|
</provider>
|
||||||
android:host="히요비.asia"
|
|
||||||
android:pathPrefix="/reader"
|
|
||||||
android:scheme="https" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<service android:name=".services.ImageCacheService"
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
android:permission="android.permission.BIND_JOB_SERVICE"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<data
|
|
||||||
android:host="xn--9w3b15m8vo.asia"
|
|
||||||
android:pathPrefix="/reader"
|
|
||||||
android:scheme="https" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
|
|
||||||
<data
|
|
||||||
android:host="e-hentai.org"
|
|
||||||
android:pathPrefix="/g"
|
|
||||||
android:scheme="https" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
|
|
||||||
<data
|
|
||||||
android:host="hitomi.la"
|
|
||||||
android:pathPrefix="/galleries"
|
|
||||||
android:scheme="http" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
|
|
||||||
<data
|
|
||||||
android:host="히요비.asia"
|
|
||||||
android:pathPrefix="/reader"
|
|
||||||
android:scheme="http" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
|
|
||||||
<data
|
|
||||||
android:host="xn--9w3b15m8vo.asia"
|
|
||||||
android:pathPrefix="/reader"
|
|
||||||
android:scheme="http" />
|
|
||||||
</intent-filter>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
|
|
||||||
<data
|
|
||||||
android:host="e-hentai.org"
|
|
||||||
android:pathPrefix="/g"
|
|
||||||
android:scheme="http" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
<activity
|
|
||||||
android:name=".ui.SettingsActivity"
|
|
||||||
android:label="@string/settings_title" />
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.MainActivity"
|
android:name=".ui.MainActivity"
|
||||||
android:configChanges="keyboardHidden|orientation|screenSize"
|
android:configChanges="keyboardHidden|orientation|screenSize"
|
||||||
android:theme="@style/NoActionBarAppTheme">
|
android:theme="@android:style/Theme.Material.Light.NoActionBar"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|||||||
@@ -1,61 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2019 tom5079
|
||||||
|
*
|
||||||
|
* This program 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.
|
||||||
|
*
|
||||||
|
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
package xyz.quaver.pupil
|
package xyz.quaver.pupil
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import com.google.firebase.FirebaseApp
|
||||||
import androidx.core.content.ContextCompat
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
import androidx.multidex.MultiDexApplication
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import androidx.preference.PreferenceManager
|
import xyz.quaver.io.FileX
|
||||||
import com.google.android.gms.common.GooglePlayServicesNotAvailableException
|
import java.util.UUID
|
||||||
import com.google.android.gms.common.GooglePlayServicesRepairableException
|
|
||||||
import com.google.android.gms.security.ProviderInstaller
|
|
||||||
import xyz.quaver.pupil.util.Histories
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class Pupil : MultiDexApplication() {
|
|
||||||
|
|
||||||
lateinit var histories: Histories
|
|
||||||
lateinit var downloads: Histories
|
|
||||||
lateinit var favorites: Histories
|
|
||||||
|
|
||||||
init {
|
|
||||||
AppCompatDelegate.setCompatVectorFromResourcesEnabled(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class Pupil : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
val preference = PreferenceManager.getDefaultSharedPreferences(this)
|
FirebaseApp.initializeApp(this)
|
||||||
|
|
||||||
histories = Histories(File(ContextCompat.getDataDir(this), "histories.json"))
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
downloads = Histories(File(ContextCompat.getDataDir(this), "downloads.json"))
|
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
favorites = Histories(File(ContextCompat.getDataDir(this), "favorites.json"))
|
|
||||||
|
|
||||||
try {
|
manager.createNotificationChannel(NotificationChannel("download", getString(R.string.channel_download), NotificationManager.IMPORTANCE_LOW).apply {
|
||||||
ProviderInstaller.installIfNeeded(this)
|
description = getString(R.string.channel_download_description)
|
||||||
} catch (e: GooglePlayServicesRepairableException) {
|
enableLights(false)
|
||||||
e.printStackTrace()
|
enableVibration(false)
|
||||||
} catch (e: GooglePlayServicesNotAvailableException) {
|
lockscreenVisibility = Notification.VISIBILITY_SECRET
|
||||||
e.printStackTrace()
|
})
|
||||||
}
|
|
||||||
|
|
||||||
if (!preference.getBoolean("channel_created", false)) {
|
manager.createNotificationChannel(NotificationChannel("downloader", getString(R.string.channel_downloader), NotificationManager.IMPORTANCE_LOW).apply {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
description = getString(R.string.channel_downloader_description)
|
||||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
enableLights(false)
|
||||||
val channel = NotificationChannel("download", getString(R.string.channel_download), NotificationManager.IMPORTANCE_LOW).apply {
|
enableVibration(false)
|
||||||
description = getString(R.string.channel_download_description)
|
lockscreenVisibility = Notification.VISIBILITY_SECRET
|
||||||
enableLights(false)
|
})
|
||||||
enableVibration(false)
|
|
||||||
lockscreenVisibility = Notification.VISIBILITY_SECRET
|
|
||||||
}
|
|
||||||
manager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
preference.edit().putBoolean("channel_created", true).apply()
|
manager.createNotificationChannel(NotificationChannel("update", getString(R.string.channel_update), NotificationManager.IMPORTANCE_HIGH).apply {
|
||||||
|
description = getString(R.string.channel_update_description)
|
||||||
|
enableLights(true)
|
||||||
|
enableVibration(true)
|
||||||
|
lockscreenVisibility = Notification.VISIBILITY_SECRET
|
||||||
|
})
|
||||||
|
|
||||||
|
manager.createNotificationChannel(NotificationChannel("import", getString(R.string.channel_update), NotificationManager.IMPORTANCE_LOW).apply {
|
||||||
|
description = getString(R.string.channel_update_description)
|
||||||
|
enableLights(false)
|
||||||
|
enableVibration(false)
|
||||||
|
lockscreenVisibility = Notification.VISIBILITY_SECRET
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,363 +0,0 @@
|
|||||||
package xyz.quaver.pupil.adapters
|
|
||||||
|
|
||||||
import android.app.AlertDialog
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import android.util.Log
|
|
||||||
import android.util.SparseBooleanArray
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.ArrayAdapter
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import androidx.cardview.widget.CardView
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import androidx.vectordrawable.graphics.drawable.Animatable2Compat
|
|
||||||
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
|
|
||||||
import com.google.android.material.chip.Chip
|
|
||||||
import kotlinx.android.synthetic.main.item_galleryblock.view.*
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Deferred
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonConfiguration
|
|
||||||
import kotlinx.serialization.list
|
|
||||||
import xyz.quaver.hitomi.GalleryBlock
|
|
||||||
import xyz.quaver.hitomi.ReaderItem
|
|
||||||
import xyz.quaver.pupil.Pupil
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.types.Tag
|
|
||||||
import xyz.quaver.pupil.util.Histories
|
|
||||||
import xyz.quaver.pupil.util.getCachedGallery
|
|
||||||
import java.io.File
|
|
||||||
import java.util.*
|
|
||||||
import kotlin.collections.ArrayList
|
|
||||||
import kotlin.collections.HashMap
|
|
||||||
import kotlin.concurrent.schedule
|
|
||||||
|
|
||||||
class GalleryBlockAdapter(private val galleries: List<Pair<GalleryBlock, Deferred<String>>>) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|
||||||
|
|
||||||
enum class ViewType {
|
|
||||||
NEXT,
|
|
||||||
GALLERY,
|
|
||||||
PREV
|
|
||||||
}
|
|
||||||
|
|
||||||
private lateinit var favorites: Histories
|
|
||||||
|
|
||||||
inner class GalleryViewHolder(private val view: CardView) : RecyclerView.ViewHolder(view) {
|
|
||||||
fun bind(item: Pair<GalleryBlock, Deferred<String>>) {
|
|
||||||
with(view) {
|
|
||||||
val resources = context.resources
|
|
||||||
val languages = resources.getStringArray(R.array.languages).map {
|
|
||||||
it.split("|").let { split ->
|
|
||||||
Pair(split[0], split[1])
|
|
||||||
}
|
|
||||||
}.toMap()
|
|
||||||
|
|
||||||
val (galleryBlock: GalleryBlock, thumbnail: Deferred<String>) = item
|
|
||||||
|
|
||||||
val artists = galleryBlock.artists
|
|
||||||
val series = galleryBlock.series
|
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.Default).launch {
|
|
||||||
val cache = thumbnail.await()
|
|
||||||
|
|
||||||
if (!File(cache).exists())
|
|
||||||
return@launch
|
|
||||||
|
|
||||||
val bitmap = BitmapFactory.decodeFile(thumbnail.await())
|
|
||||||
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
galleryblock_thumbnail.setImageBitmap(bitmap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//Check cache
|
|
||||||
val readerCache = { File(getCachedGallery(context, galleryBlock.id), "reader.json") }
|
|
||||||
val imageCache = { File(getCachedGallery(context, galleryBlock.id), "images") }
|
|
||||||
|
|
||||||
if (readerCache.invoke().exists()) {
|
|
||||||
val reader = Json(JsonConfiguration.Stable)
|
|
||||||
.parse(ReaderItem.serializer().list, readerCache.invoke().readText())
|
|
||||||
|
|
||||||
with(galleryblock_progressbar) {
|
|
||||||
max = reader.size
|
|
||||||
progress = imageCache.invoke().list()?.size ?: 0
|
|
||||||
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
galleryblock_progressbar.visibility = View.GONE
|
|
||||||
}
|
|
||||||
|
|
||||||
if (refreshTasks[this@GalleryViewHolder] == null) {
|
|
||||||
val refresh = Timer(false).schedule(0, 1000) {
|
|
||||||
post {
|
|
||||||
with(view.galleryblock_progressbar) {
|
|
||||||
progress = imageCache.invoke().list()?.size ?: 0
|
|
||||||
|
|
||||||
if (!readerCache.invoke().exists()) {
|
|
||||||
visibility = View.GONE
|
|
||||||
max = 0
|
|
||||||
progress = 0
|
|
||||||
|
|
||||||
view.galleryblock_progress_complete.visibility = View.INVISIBLE
|
|
||||||
} else {
|
|
||||||
if (visibility == View.GONE) {
|
|
||||||
val reader = Json(JsonConfiguration.Stable)
|
|
||||||
.parse(ReaderItem.serializer().list, readerCache.invoke().readText())
|
|
||||||
max = reader.size
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progress == max) {
|
|
||||||
if (completeFlag.get(galleryBlock.id, false)) {
|
|
||||||
with(view.galleryblock_progress_complete) {
|
|
||||||
setImageResource(R.drawable.ic_progressbar)
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
with(view.galleryblock_progress_complete) {
|
|
||||||
setImageDrawable(AnimatedVectorDrawableCompat.create(context, R.drawable.ic_progressbar_complete).apply {
|
|
||||||
this?.start()
|
|
||||||
})
|
|
||||||
visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
completeFlag.put(galleryBlock.id, true)
|
|
||||||
}
|
|
||||||
} else
|
|
||||||
view.galleryblock_progress_complete.visibility = View.INVISIBLE
|
|
||||||
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshTasks[this@GalleryViewHolder] = refresh
|
|
||||||
}
|
|
||||||
|
|
||||||
galleryblock_title.text = galleryBlock.title
|
|
||||||
with(galleryblock_artist) {
|
|
||||||
text = artists.joinToString(", ") { it.wordCapitalize() }
|
|
||||||
visibility = when {
|
|
||||||
artists.isNotEmpty() -> View.VISIBLE
|
|
||||||
else -> View.GONE
|
|
||||||
}
|
|
||||||
setOnClickListener {
|
|
||||||
if (artists.size > 1) {
|
|
||||||
AlertDialog.Builder(context).apply {
|
|
||||||
setAdapter(ArrayAdapter(context, android.R.layout.select_dialog_item, artists)) { _, index ->
|
|
||||||
for (callback in onChipClickedHandler)
|
|
||||||
callback.invoke(Tag("artist", artists[index]))
|
|
||||||
}
|
|
||||||
}.show()
|
|
||||||
} else {
|
|
||||||
for(callback in onChipClickedHandler)
|
|
||||||
callback.invoke(Tag("artist", artists.first()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
with(galleryblock_series) {
|
|
||||||
text =
|
|
||||||
resources.getString(
|
|
||||||
R.string.galleryblock_series,
|
|
||||||
series.joinToString(", ") { it.wordCapitalize() })
|
|
||||||
visibility = when {
|
|
||||||
series.isNotEmpty() -> View.VISIBLE
|
|
||||||
else -> View.GONE
|
|
||||||
}
|
|
||||||
setOnClickListener {
|
|
||||||
setOnClickListener {
|
|
||||||
if (series.size > 1) {
|
|
||||||
AlertDialog.Builder(context).apply {
|
|
||||||
setAdapter(ArrayAdapter(context, android.R.layout.select_dialog_item, series)) { _, index ->
|
|
||||||
for (callback in onChipClickedHandler)
|
|
||||||
callback.invoke(Tag("series", series[index]))
|
|
||||||
}
|
|
||||||
}.show()
|
|
||||||
} else {
|
|
||||||
for(callback in onChipClickedHandler)
|
|
||||||
callback.invoke(Tag("series", series.first()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
with(galleryblock_type) {
|
|
||||||
text = resources.getString(R.string.galleryblock_type, galleryBlock.type).wordCapitalize()
|
|
||||||
setOnClickListener {
|
|
||||||
setOnClickListener {
|
|
||||||
for(callback in onChipClickedHandler)
|
|
||||||
callback.invoke(Tag("type", galleryBlock.type))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
with(galleryblock_language) {
|
|
||||||
text =
|
|
||||||
resources.getString(R.string.galleryblock_language, languages[galleryBlock.language])
|
|
||||||
visibility = when {
|
|
||||||
galleryBlock.language.isNotEmpty() -> View.VISIBLE
|
|
||||||
else -> View.GONE
|
|
||||||
}
|
|
||||||
setOnClickListener {
|
|
||||||
setOnClickListener {
|
|
||||||
for(callback in onChipClickedHandler)
|
|
||||||
callback.invoke(Tag("language", galleryBlock.language))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
galleryblock_tag_group.removeAllViews()
|
|
||||||
galleryBlock.relatedTags.forEach {
|
|
||||||
val tag = Tag.parse(it).let { tag ->
|
|
||||||
when {
|
|
||||||
tag.area != null -> tag
|
|
||||||
else -> Tag("tag", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val chip = LayoutInflater.from(context)
|
|
||||||
.inflate(R.layout.tag_chip, this, false) as Chip
|
|
||||||
|
|
||||||
val icon = when(tag.area) {
|
|
||||||
"male" -> {
|
|
||||||
chip.setChipBackgroundColorResource(R.color.material_blue_700)
|
|
||||||
chip.setTextColor(ContextCompat.getColor(context, android.R.color.white))
|
|
||||||
ContextCompat.getDrawable(context, R.drawable.ic_gender_male_white)
|
|
||||||
}
|
|
||||||
"female" -> {
|
|
||||||
chip.setChipBackgroundColorResource(R.color.material_pink_600)
|
|
||||||
chip.setTextColor(ContextCompat.getColor(context, android.R.color.white))
|
|
||||||
ContextCompat.getDrawable(context, R.drawable.ic_gender_female_white)
|
|
||||||
}
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
chip.chipIcon = icon
|
|
||||||
chip.text = tag.tag.wordCapitalize()
|
|
||||||
chip.setOnClickListener {
|
|
||||||
for (callback in onChipClickedHandler)
|
|
||||||
callback.invoke(tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
galleryblock_tag_group.addView(chip)
|
|
||||||
}
|
|
||||||
|
|
||||||
galleryblock_id.text = galleryBlock.id.toString()
|
|
||||||
|
|
||||||
if (!::favorites.isInitialized)
|
|
||||||
favorites = (context.applicationContext as Pupil).favorites
|
|
||||||
|
|
||||||
with(galleryblock_favorite) {
|
|
||||||
setImageResource(if (favorites.contains(galleryBlock.id)) R.drawable.ic_star_filled else R.drawable.ic_star_empty)
|
|
||||||
setOnClickListener {
|
|
||||||
when {
|
|
||||||
favorites.contains(galleryBlock.id) -> {
|
|
||||||
favorites.remove(galleryBlock.id)
|
|
||||||
|
|
||||||
setImageResource(R.drawable.ic_star_empty)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
favorites.add(galleryBlock.id)
|
|
||||||
|
|
||||||
setImageDrawable(AnimatedVectorDrawableCompat.create(context, R.drawable.avd_star).apply {
|
|
||||||
this ?: return@apply
|
|
||||||
|
|
||||||
registerAnimationCallback(object: Animatable2Compat.AnimationCallback() {
|
|
||||||
override fun onAnimationEnd(drawable: Drawable?) {
|
|
||||||
setImageResource(R.drawable.ic_star_filled)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
start()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class NextViewHolder(view: LinearLayout) : RecyclerView.ViewHolder(view)
|
|
||||||
class PrevViewHolder(view: LinearLayout) : RecyclerView.ViewHolder(view)
|
|
||||||
|
|
||||||
class ViewHolderFactory {
|
|
||||||
companion object {
|
|
||||||
fun getLayoutID(type: Int): Int {
|
|
||||||
return when(ViewType.values()[type]) {
|
|
||||||
ViewType.NEXT -> R.layout.item_next
|
|
||||||
ViewType.PREV -> R.layout.item_prev
|
|
||||||
ViewType.GALLERY -> R.layout.item_galleryblock
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.wordCapitalize() : String {
|
|
||||||
val result = ArrayList<String>()
|
|
||||||
|
|
||||||
for (word in this.split(" "))
|
|
||||||
result.add(word.capitalize())
|
|
||||||
|
|
||||||
return result.joinToString(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
private val refreshTasks = HashMap<GalleryViewHolder, TimerTask>()
|
|
||||||
val completeFlag = SparseBooleanArray()
|
|
||||||
|
|
||||||
val onChipClickedHandler = ArrayList<((Tag) -> Unit)>()
|
|
||||||
|
|
||||||
var showNext = false
|
|
||||||
var showPrev = false
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
|
||||||
|
|
||||||
fun getViewHolder(type: Int, view: View): RecyclerView.ViewHolder {
|
|
||||||
return when(ViewType.values()[type]) {
|
|
||||||
ViewType.NEXT -> NextViewHolder(view as LinearLayout)
|
|
||||||
ViewType.PREV -> PrevViewHolder(view as LinearLayout)
|
|
||||||
ViewType.GALLERY -> GalleryViewHolder(view as CardView)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return getViewHolder(
|
|
||||||
viewType,
|
|
||||||
LayoutInflater.from(parent.context).inflate(
|
|
||||||
ViewHolderFactory.getLayoutID(viewType),
|
|
||||||
parent,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
|
||||||
if (holder is GalleryViewHolder)
|
|
||||||
holder.bind(galleries[position-(if (showPrev) 1 else 0)])
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
|
|
||||||
super.onViewDetachedFromWindow(holder)
|
|
||||||
|
|
||||||
if (holder is GalleryViewHolder) {
|
|
||||||
val task = refreshTasks[holder] ?: return
|
|
||||||
|
|
||||||
task.cancel()
|
|
||||||
refreshTasks.remove(holder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount() =
|
|
||||||
(if (galleries.isEmpty()) 0 else galleries.size)+
|
|
||||||
(if (showNext) 1 else 0)+
|
|
||||||
(if (showPrev) 1 else 0)
|
|
||||||
|
|
||||||
override fun getItemViewType(position: Int): Int {
|
|
||||||
return when {
|
|
||||||
showPrev && position == 0 -> ViewType.PREV
|
|
||||||
showNext && position == galleries.size+(if (showPrev) 1 else 0) -> ViewType.NEXT
|
|
||||||
else -> ViewType.GALLERY
|
|
||||||
}.ordinal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package xyz.quaver.pupil.adapters
|
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.util.Log
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.ImageView
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
|
|
||||||
class ReaderAdapter(private val images: List<String>) : RecyclerView.Adapter<ReaderAdapter.ViewHolder>() {
|
|
||||||
|
|
||||||
var isFullScreen = false
|
|
||||||
|
|
||||||
class ViewHolder(val view: View) : RecyclerView.ViewHolder(view)
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
|
||||||
LayoutInflater.from(parent.context).inflate(
|
|
||||||
R.layout.item_reader, parent, false
|
|
||||||
).let {
|
|
||||||
return ViewHolder(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
|
||||||
|
|
||||||
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
|
|
||||||
// Raw height and width of image
|
|
||||||
val (height: Int, width: Int) = options.run { outHeight to outWidth }
|
|
||||||
var inSampleSize = 1
|
|
||||||
|
|
||||||
if (height > reqHeight || width > reqWidth) {
|
|
||||||
|
|
||||||
val halfHeight: Int = height / 2
|
|
||||||
val halfWidth: Int = width / 2
|
|
||||||
|
|
||||||
// Calculate the largest inSampleSize value that is a power of 2 and keeps both
|
|
||||||
// height and width larger than the requested height and width.
|
|
||||||
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
|
|
||||||
inSampleSize *= 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return inSampleSize
|
|
||||||
}
|
|
||||||
|
|
||||||
with(holder.view as ImageView) {
|
|
||||||
val options = BitmapFactory.Options()
|
|
||||||
|
|
||||||
options.inJustDecodeBounds = true
|
|
||||||
BitmapFactory.decodeFile(images[position], options)
|
|
||||||
|
|
||||||
val (reqWidth, reqHeight) = context.resources.displayMetrics.let {
|
|
||||||
Pair(it.widthPixels, it.heightPixels)
|
|
||||||
}
|
|
||||||
|
|
||||||
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight)
|
|
||||||
|
|
||||||
options.inPreferredConfig = Bitmap.Config.RGB_565
|
|
||||||
|
|
||||||
options.inJustDecodeBounds = false
|
|
||||||
|
|
||||||
val image = BitmapFactory.decodeFile(images[position], options)
|
|
||||||
|
|
||||||
setImageBitmap(image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getItemCount() = images.size
|
|
||||||
|
|
||||||
}
|
|
||||||
24
app/src/main/java/xyz/quaver/pupil/di/SingletonModule.kt
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package xyz.quaver.pupil.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import xyz.quaver.pupil.networking.FileImageCache
|
||||||
|
import xyz.quaver.pupil.networking.ImageCache
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object SingletonModule {
|
||||||
|
@Singleton
|
||||||
|
@Provides
|
||||||
|
fun provideImageCache(
|
||||||
|
@ApplicationContext context: Context
|
||||||
|
): ImageCache {
|
||||||
|
return FileImageCache(File(context.cacheDir, "image_cache"))
|
||||||
|
}
|
||||||
|
}
|
||||||
110
app/src/main/java/xyz/quaver/pupil/networking/GalleryInfo.kt
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package xyz.quaver.pupil.networking
|
||||||
|
|
||||||
|
import android.os.BaseBundle
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
interface TagLike {
|
||||||
|
fun toTag(): SearchQuery.Tag
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Artist(val artist: String): TagLike {
|
||||||
|
override fun toTag() = SearchQuery.Tag("artist", artist)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Group(val group: String): TagLike {
|
||||||
|
override fun toTag() = SearchQuery.Tag("group", group)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Series(@SerialName("parody") val series: String): TagLike {
|
||||||
|
override fun toTag() = SearchQuery.Tag("series", series)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Character(val character: String): TagLike {
|
||||||
|
override fun toTag() = SearchQuery.Tag("character", character)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GalleryTag(
|
||||||
|
val tag: String,
|
||||||
|
val female: String? = null,
|
||||||
|
val male: String? = null
|
||||||
|
): TagLike {
|
||||||
|
override fun toTag() = SearchQuery.Tag(
|
||||||
|
if (female.isNullOrEmpty() && male.isNullOrEmpty()) {
|
||||||
|
"tag"
|
||||||
|
} else if (male.isNullOrEmpty()) {
|
||||||
|
"female"
|
||||||
|
} else {
|
||||||
|
"male"
|
||||||
|
},
|
||||||
|
tag
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Language(
|
||||||
|
@SerialName("galleryid") val galleryID: String,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GalleryFile(
|
||||||
|
@SerialName("haswebp") val hasWebP: Int = 0,
|
||||||
|
@SerialName("hasavif") val hasAVIF: Int = 0,
|
||||||
|
@SerialName("hasjxl") val hasJXL: Int = 0,
|
||||||
|
val height: Int,
|
||||||
|
val width: Int,
|
||||||
|
val hash: String,
|
||||||
|
val name: String,
|
||||||
|
) {
|
||||||
|
fun writeToBundle(bundle: BaseBundle) {
|
||||||
|
bundle.putInt("hasWebP", hasWebP)
|
||||||
|
bundle.putInt("hasAVIF", hasAVIF)
|
||||||
|
bundle.putInt("hasJXL", hasJXL)
|
||||||
|
bundle.putInt("height", height)
|
||||||
|
bundle.putInt("width", width)
|
||||||
|
bundle.putString("hash", hash)
|
||||||
|
bundle.putString("name", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromBundle(bundle: BaseBundle) = GalleryFile(
|
||||||
|
bundle.getInt("hasWebP"),
|
||||||
|
bundle.getInt("hasAVIF"),
|
||||||
|
bundle.getInt("hasJXL"),
|
||||||
|
bundle.getInt("height"),
|
||||||
|
bundle.getInt("width"),
|
||||||
|
bundle.getString("hash")!!,
|
||||||
|
bundle.getString("name")!!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GalleryInfo(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
@SerialName("japanese_title") val japaneseTitle: String? = null,
|
||||||
|
val language: String? = null,
|
||||||
|
val type: String,
|
||||||
|
val date: String,
|
||||||
|
val artists: List<Artist>? = null,
|
||||||
|
val groups: List<Group>? = null,
|
||||||
|
@SerialName("parodys") val series: List<Series>? = null,
|
||||||
|
val tags: List<GalleryTag>? = null,
|
||||||
|
val related: List<Int> = emptyList(),
|
||||||
|
val languages: List<Language> = emptyList(),
|
||||||
|
val characters: List<Character>? = null,
|
||||||
|
@SerialName("scene_indexes") val sceneIndices: List<Int>? = emptyList(),
|
||||||
|
val files: List<GalleryFile> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
@JvmName("joinToCapitalizedStringArtist")
|
||||||
|
fun List<Artist>.joinToCapitalizedString() = joinToString { it.artist.replaceFirstChar(Char::titlecase) }
|
||||||
|
@JvmName("joinToCapitalizedStringGroup")
|
||||||
|
fun List<Group>.joinToCapitalizedString() = joinToString { it.group.replaceFirstChar(Char::titlecase) }
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package xyz.quaver.pupil.networking
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
|
||||||
|
class GallerySearchSource(val query: SearchQuery?) {
|
||||||
|
private var searchResult: List<Int>? = null
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
suspend fun load(range: IntRange): Result<Pair<List<GalleryInfo>, Int>> = runCatching {
|
||||||
|
val searchResult = searchResult ?: (
|
||||||
|
HitomiHttpClient
|
||||||
|
.search(query)
|
||||||
|
.getOrThrow()
|
||||||
|
.toList()
|
||||||
|
.also { searchResult = it }
|
||||||
|
)
|
||||||
|
|
||||||
|
val galleryResults = coroutineScope {
|
||||||
|
searchResult.slice(range).map { galleryID ->
|
||||||
|
async {
|
||||||
|
HitomiHttpClient.getGalleryInfo(galleryID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val galleries = galleryResults.map { result ->
|
||||||
|
result.await().getOrThrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
Pair(galleries, searchResult.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() = job?.cancel()
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
package xyz.quaver.pupil.networking
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.engine.okhttp.OkHttp
|
||||||
|
import io.ktor.client.plugins.onDownload
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.client.statement.HttpResponse
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.utils.io.ByteReadChannel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.datetime.Clock.System.now
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.IntBuffer
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
const val domain = "ltn.hitomi.la"
|
||||||
|
const val nozomiExtension = ".nozomi"
|
||||||
|
|
||||||
|
const val compressedNozomiPrefix = "n"
|
||||||
|
|
||||||
|
const val B = 16
|
||||||
|
const val indexDir = "tagindex"
|
||||||
|
const val maxNodeSize = 464
|
||||||
|
const val galleriesIndexDir = "galleriesindex"
|
||||||
|
const val tagIndexDomain = "tagindex.hitomi.la"
|
||||||
|
|
||||||
|
const val separator = "-"
|
||||||
|
const val extension = ".html"
|
||||||
|
|
||||||
|
data class Suggestion(
|
||||||
|
val tag: SearchQuery.Tag,
|
||||||
|
val count: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun IntBuffer.toSet(): Set<Int> {
|
||||||
|
val result = LinkedHashSet<Int>()
|
||||||
|
|
||||||
|
while (this.hasRemaining()) {
|
||||||
|
result.add(this.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
isLenient = true
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImagePathResolver(ggjs: String) {
|
||||||
|
private val defaultPrefix: Int = Regex("var o = (\\d)").find(ggjs)!!.groupValues[1].toInt()
|
||||||
|
private val prefixMap: Map<Int, Int> = buildMap {
|
||||||
|
val o = Regex("o = (\\d); break;").find(ggjs)!!.groupValues[1].toInt()
|
||||||
|
|
||||||
|
Regex("case (\\d+):").findAll(ggjs).forEach {
|
||||||
|
val case = it.groupValues[1].toInt()
|
||||||
|
put(case, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val imageBaseDir: String = Regex("b: '(.+)'").find(ggjs)!!.groupValues[1]
|
||||||
|
|
||||||
|
fun decodeSubdomain(hash: String, thumbnail: Boolean): String {
|
||||||
|
val key = (hash.last() + hash.dropLast(1).takeLast(2)).toInt(16)
|
||||||
|
val base = if (thumbnail) "tn" else "a"
|
||||||
|
|
||||||
|
return "${'a' + (prefixMap[key] ?: defaultPrefix)}$base"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeImagePath(hash: String, thumbnail: Boolean): String {
|
||||||
|
val key = hash.last() to hash.dropLast(1).takeLast(2)
|
||||||
|
|
||||||
|
return if (thumbnail) {
|
||||||
|
"${key.first}/${key.second}/$hash"
|
||||||
|
} else {
|
||||||
|
"$imageBaseDir/${(key.first + key.second).toInt(16)}/$hash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExpirableEntry<T>(
|
||||||
|
private val expiryDuration: Duration,
|
||||||
|
private val action: suspend () -> T,
|
||||||
|
) {
|
||||||
|
private var value: T? = null
|
||||||
|
private var expiresAt: Instant = now()
|
||||||
|
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
suspend fun getValue(): T = mutex.withLock {
|
||||||
|
value?.let { if (expiresAt > now()) value else null } ?: action().also {
|
||||||
|
expiresAt = now() + expiryDuration
|
||||||
|
value = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object HitomiHttpClient {
|
||||||
|
private val httpClient = HttpClient(OkHttp) {
|
||||||
|
engine {
|
||||||
|
config {
|
||||||
|
sslSocketFactory(SSLSettings.sslContext!!.socketFactory, SSLSettings.trustManager!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var imagePathResolver = ExpirableEntry(1.minutes) {
|
||||||
|
ImagePathResolver(httpClient.get("https://ltn.hitomi.la/gg.js").bodyAsText())
|
||||||
|
}
|
||||||
|
|
||||||
|
private val tagIndexVersion = ExpirableEntry(1.minutes) { getIndexVersion("tagindex") }
|
||||||
|
private val galleriesIndexVersion =
|
||||||
|
ExpirableEntry(1.minutes) { getIndexVersion("galleriesindex") }
|
||||||
|
|
||||||
|
private suspend fun getIndexVersion(name: String): String = withContext(Dispatchers.IO) {
|
||||||
|
httpClient.get("https://$domain/$name/version?_=${System.currentTimeMillis()}").bodyAsText()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getURLAtRange(url: String, range: LongRange): ByteBuffer {
|
||||||
|
val response: HttpResponse = withContext(Dispatchers.IO) {
|
||||||
|
httpClient.get(url) {
|
||||||
|
header("Range", "bytes=${range.first}-${range.last}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val result: ByteArray = response.body()
|
||||||
|
|
||||||
|
return ByteBuffer.wrap(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getNodeAtAddress(field: String, address: Long): Node {
|
||||||
|
val url = when (field) {
|
||||||
|
"galleries" -> "https://$domain/$galleriesIndexDir/galleries.${galleriesIndexVersion.getValue()}.index"
|
||||||
|
"languages" -> "https://$domain/$galleriesIndexDir/languages.${galleriesIndexVersion.getValue()}.index"
|
||||||
|
"nozomiurl" -> "https://$domain/$galleriesIndexDir/nozomiurl.${galleriesIndexVersion.getValue()}.index"
|
||||||
|
else -> "https://$domain/$indexDir/$field.${tagIndexVersion.getValue()}.index"
|
||||||
|
}
|
||||||
|
|
||||||
|
return Node.decodeNode(
|
||||||
|
getURLAtRange(url, address..<address + maxNodeSize)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun bSearch(
|
||||||
|
field: String,
|
||||||
|
key: Node.Key,
|
||||||
|
node: Node,
|
||||||
|
): Node.Data? {
|
||||||
|
if (node.keys.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val (matched, index) = node.locateKey(key)
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
return node.datas[index]
|
||||||
|
} else if (node.isLeaf) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val nextNode = getNodeAtAddress(field, node.subNodeAddresses[index])
|
||||||
|
return bSearch(field, key, nextNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getGalleryIDsFromData(offset: Long, length: Int): IntBuffer {
|
||||||
|
val url =
|
||||||
|
"https://$domain/$galleriesIndexDir/galleries.${galleriesIndexVersion.getValue()}.data"
|
||||||
|
if (length > 100000000 || length <= 0) {
|
||||||
|
error("length $length is too long")
|
||||||
|
}
|
||||||
|
|
||||||
|
return getURLAtRange(url, offset until (offset + length)).asIntBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodeSearchQueryForUrl(s: Char) =
|
||||||
|
when (s) {
|
||||||
|
' ' -> "_"
|
||||||
|
'/' -> "slash"
|
||||||
|
'.' -> "dot"
|
||||||
|
else -> s.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sanitize(s: String) = s.replace(Regex("[/#]"), "")
|
||||||
|
|
||||||
|
private suspend fun getGalleryIDsFromNozomi(
|
||||||
|
area: String?,
|
||||||
|
tag: String,
|
||||||
|
language: String,
|
||||||
|
): IntBuffer {
|
||||||
|
val nozomiAddress = if (area == null) {
|
||||||
|
"https://$domain/$compressedNozomiPrefix/$tag-$language$nozomiExtension"
|
||||||
|
} else {
|
||||||
|
"https://$domain/$compressedNozomiPrefix/$area/$tag-$language$nozomiExtension"
|
||||||
|
}
|
||||||
|
|
||||||
|
val response: HttpResponse = withContext(Dispatchers.IO) {
|
||||||
|
httpClient.get(nozomiAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result: ByteArray = response.body()
|
||||||
|
|
||||||
|
return ByteBuffer.wrap(result).asIntBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getGalleryIDsForQuery(
|
||||||
|
query: SearchQuery.Tag,
|
||||||
|
language: String = "all",
|
||||||
|
): IntBuffer = when (query.namespace) {
|
||||||
|
"female", "male" -> getGalleryIDsFromNozomi("tag", query.toString(), language)
|
||||||
|
"language" -> getGalleryIDsFromNozomi(null, "index", query.tag)
|
||||||
|
null -> {
|
||||||
|
val key = Node.Key(query.tag)
|
||||||
|
|
||||||
|
val node = getNodeAtAddress("galleries", 0)
|
||||||
|
val data = bSearch("galleries", key, node)
|
||||||
|
|
||||||
|
if (data != null) getGalleryIDsFromData(
|
||||||
|
data.offset,
|
||||||
|
data.length
|
||||||
|
) else IntBuffer.allocate(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> getGalleryIDsFromNozomi(query.namespace, query.tag, language)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getSuggestionsForQuery(query: SearchQuery.Tag): Result<List<Suggestion>> =
|
||||||
|
runCatching {
|
||||||
|
val field = query.namespace ?: "global"
|
||||||
|
val chars = query.tag.map(::encodeSearchQueryForUrl)
|
||||||
|
|
||||||
|
val suggestions = json.parseToJsonElement(
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
httpClient.get(
|
||||||
|
"https://$tagIndexDomain/$field${
|
||||||
|
if (chars.isNotEmpty()) "/${
|
||||||
|
chars.joinToString(
|
||||||
|
"/"
|
||||||
|
)
|
||||||
|
}" else ""
|
||||||
|
}.json"
|
||||||
|
).bodyAsText()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
buildList {
|
||||||
|
suggestions.jsonArray.forEach { suggestionRaw ->
|
||||||
|
val suggestion = suggestionRaw.jsonArray
|
||||||
|
if (suggestion.size < 3) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
val namespace = suggestion[2].jsonPrimitive.contentOrNull ?: ""
|
||||||
|
|
||||||
|
val tag =
|
||||||
|
sanitize(suggestion[0].jsonPrimitive.contentOrNull ?: return@forEach)
|
||||||
|
|
||||||
|
add(
|
||||||
|
Suggestion(
|
||||||
|
SearchQuery.Tag(
|
||||||
|
namespace,
|
||||||
|
tag
|
||||||
|
),
|
||||||
|
suggestion[1].jsonPrimitive.contentOrNull?.toIntOrNull() ?: 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getGalleryInfo(galleryID: Int) = runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
json.decodeFromString<GalleryInfo>(
|
||||||
|
httpClient.get("https://$domain/galleries/$galleryID.js").bodyAsText()
|
||||||
|
.replace("var galleryinfo = ", "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun search(query: SearchQuery?): Result<Set<Int>> = runCatching {
|
||||||
|
when (query) {
|
||||||
|
is SearchQuery.Tag -> getGalleryIDsForQuery(query).toSet()
|
||||||
|
is SearchQuery.Not -> coroutineScope {
|
||||||
|
val allGalleries = async {
|
||||||
|
getGalleryIDsFromNozomi(null, "index", "all")
|
||||||
|
}
|
||||||
|
|
||||||
|
val queriedGalleries = search(query.query).getOrThrow()
|
||||||
|
|
||||||
|
val result = LinkedHashSet<Int>()
|
||||||
|
|
||||||
|
with(allGalleries.await()) {
|
||||||
|
while (this.hasRemaining()) {
|
||||||
|
val gallery = this.get()
|
||||||
|
|
||||||
|
if (gallery in queriedGalleries) {
|
||||||
|
result.add(gallery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
is SearchQuery.And -> coroutineScope {
|
||||||
|
val queries = query.queries.map { query ->
|
||||||
|
async {
|
||||||
|
search(query).getOrThrow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = queries.first().await().toMutableSet()
|
||||||
|
|
||||||
|
queries.drop(1).forEach {
|
||||||
|
val queryResult = it.await()
|
||||||
|
|
||||||
|
result.retainAll(queryResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
is SearchQuery.Or -> coroutineScope {
|
||||||
|
val queries = query.queries.map { query ->
|
||||||
|
async {
|
||||||
|
search(query).getOrThrow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = LinkedHashSet<Int>()
|
||||||
|
|
||||||
|
queries.forEach {
|
||||||
|
val queryResult = it.await()
|
||||||
|
result.addAll(queryResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> getGalleryIDsFromNozomi(null, "index", "all").toSet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getImageURL(galleryFile: GalleryFile, thumbnail: Boolean = false): List<String> =
|
||||||
|
buildList {
|
||||||
|
val imagePathResolver = imagePathResolver.getValue()
|
||||||
|
|
||||||
|
listOf("webp", "avif", "jxl").forEach { type ->
|
||||||
|
val available = when {
|
||||||
|
thumbnail && type != "jxl" -> true
|
||||||
|
type == "webp" -> galleryFile.hasWebP != 0
|
||||||
|
type == "avif" -> galleryFile.hasAVIF != 0
|
||||||
|
!thumbnail && type == "jxl" -> galleryFile.hasJXL != 0
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!available) return@forEach
|
||||||
|
|
||||||
|
val url = buildString {
|
||||||
|
append("https://")
|
||||||
|
append(imagePathResolver.decodeSubdomain(galleryFile.hash, thumbnail))
|
||||||
|
append(".hitomi.la/")
|
||||||
|
append(type)
|
||||||
|
if (thumbnail) append("bigtn")
|
||||||
|
append('/')
|
||||||
|
append(imagePathResolver.decodeImagePath(galleryFile.hash, thumbnail))
|
||||||
|
append('.')
|
||||||
|
append(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadImage(
|
||||||
|
galleryFile: GalleryFile,
|
||||||
|
thumbnail: Boolean = false,
|
||||||
|
acceptImage: (String) -> Boolean = { true },
|
||||||
|
onDownload: (bytesSentTotal: Long, contentLength: Long?) -> Unit = { _, _ -> },
|
||||||
|
): Result<Pair<ByteReadChannel, String>> {
|
||||||
|
return runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val url = getImageURL(galleryFile, thumbnail).firstOrNull(acceptImage)
|
||||||
|
?: error("No available image")
|
||||||
|
val channel: ByteReadChannel = httpClient.get(url) { onDownload(onDownload) }.body()
|
||||||
|
Pair(channel, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
135
app/src/main/java/xyz/quaver/pupil/networking/ImageCache.kt
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
package xyz.quaver.pupil.networking
|
||||||
|
|
||||||
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
|
import io.ktor.util.cio.writeChannel
|
||||||
|
import io.ktor.utils.io.copyAndClose
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
sealed class ImageLoadProgress {
|
||||||
|
data object NotStarted : ImageLoadProgress()
|
||||||
|
data class Progress(val bytesSent: Long, val contentLength: Long?) : ImageLoadProgress()
|
||||||
|
data class Finished(val file: File) : ImageLoadProgress()
|
||||||
|
data class Error(val exception: Throwable) : ImageLoadProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImageCache {
|
||||||
|
suspend fun load(
|
||||||
|
galleryFile: GalleryFile,
|
||||||
|
forceDownload: Boolean = false,
|
||||||
|
): StateFlow<ImageLoadProgress>
|
||||||
|
|
||||||
|
suspend fun free(vararg files: GalleryFile)
|
||||||
|
suspend fun clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileImageCache(
|
||||||
|
private val cacheDir: File,
|
||||||
|
private val cacheLimit: Long = 128 * 1024 * 1024, // 128MB
|
||||||
|
) : ImageCache {
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
private val requests = mutableMapOf<String, Pair<Job, StateFlow<ImageLoadProgress>>>()
|
||||||
|
private val activeFiles = mutableMapOf<String, File>()
|
||||||
|
|
||||||
|
private suspend fun cleanup() = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock {
|
||||||
|
val size = cacheDir.listFiles()?.sumOf { it.length() } ?: 0
|
||||||
|
|
||||||
|
if (size > cacheLimit) {
|
||||||
|
cacheDir.listFiles { file ->
|
||||||
|
file.nameWithoutExtension !in activeFiles
|
||||||
|
}?.forEach { file ->
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun free(vararg files: GalleryFile) = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock {
|
||||||
|
files.forEach { file ->
|
||||||
|
val hash = file.hash
|
||||||
|
|
||||||
|
requests[hash]?.let { (job, _) ->
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
requests.remove(hash)
|
||||||
|
activeFiles.remove(hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clear(): Unit = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock {
|
||||||
|
requests.forEach { _, (job, _) -> job.cancel() }
|
||||||
|
activeFiles.clear()
|
||||||
|
cacheDir.deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun load(
|
||||||
|
galleryFile: GalleryFile,
|
||||||
|
forceDownload: Boolean,
|
||||||
|
): StateFlow<ImageLoadProgress> {
|
||||||
|
val hash = galleryFile.hash
|
||||||
|
|
||||||
|
mutex.withLock {
|
||||||
|
val file = activeFiles[hash]
|
||||||
|
if (!forceDownload && file != null) {
|
||||||
|
return MutableStateFlow(ImageLoadProgress.Finished(file))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
mutex.withLock {
|
||||||
|
requests[hash]?.first?.cancelAndJoin()
|
||||||
|
activeFiles[hash]?.delete()
|
||||||
|
|
||||||
|
val flow = MutableStateFlow<ImageLoadProgress>(ImageLoadProgress.NotStarted)
|
||||||
|
val job = coroutineScope {
|
||||||
|
launch {
|
||||||
|
runCatching {
|
||||||
|
val (channel, url) = HitomiHttpClient.loadImage(galleryFile) { sent, total ->
|
||||||
|
flow.value = ImageLoadProgress.Progress(sent, total)
|
||||||
|
}.onFailure {
|
||||||
|
FirebaseCrashlytics.getInstance().recordException(it)
|
||||||
|
flow.value = ImageLoadProgress.Error(it)
|
||||||
|
}.getOrThrow()
|
||||||
|
|
||||||
|
val file = File(cacheDir, "$hash.${url.substringAfterLast('.')}")
|
||||||
|
|
||||||
|
mutex.withLock {
|
||||||
|
activeFiles.put(hash, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
channel.copyAndClose(file.writeChannel())
|
||||||
|
|
||||||
|
file
|
||||||
|
}.onSuccess { file ->
|
||||||
|
flow.value = ImageLoadProgress.Finished(file)
|
||||||
|
}.onFailure {
|
||||||
|
activeFiles.remove(hash)
|
||||||
|
FirebaseCrashlytics.getInstance().recordException(it)
|
||||||
|
flow.value = ImageLoadProgress.Error(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requests[hash] = job to flow
|
||||||
|
|
||||||
|
return flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
107
app/src/main/java/xyz/quaver/pupil/networking/Node.kt
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
@file:OptIn(ExperimentalUnsignedTypes::class)
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.networking
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
private fun sha256(data: ByteArray): ByteArray =
|
||||||
|
MessageDigest.getInstance("SHA-256").digest(data)
|
||||||
|
|
||||||
|
private fun hashTerm(term: String): UByteArray =
|
||||||
|
sha256(term.toByteArray()).sliceArray(0..<4).toUByteArray()
|
||||||
|
|
||||||
|
data class Node(
|
||||||
|
val keys: List<Key>,
|
||||||
|
val datas: List<Data>,
|
||||||
|
val subNodeAddresses: List<Long>
|
||||||
|
) {
|
||||||
|
data class Key(
|
||||||
|
private val key: UByteArray
|
||||||
|
): Comparable<Key> {
|
||||||
|
|
||||||
|
constructor(term: String): this(hashTerm(term))
|
||||||
|
|
||||||
|
override fun compareTo(other: Key): Int {
|
||||||
|
val minSize = min(this.key.size, other.key.size)
|
||||||
|
|
||||||
|
for (i in 0..<minSize) {
|
||||||
|
if (this.key[i] < other.key[i]) {
|
||||||
|
return -1
|
||||||
|
} else if(this.key[i] > other.key[i]) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as Key
|
||||||
|
|
||||||
|
return key.contentEquals(other.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return key.contentHashCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Data(
|
||||||
|
val offset: Long,
|
||||||
|
val length: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun decodeNode(buffer: ByteBuffer): Node {
|
||||||
|
val numberOfKeys = buffer.int
|
||||||
|
val keys = mutableListOf<Node.Key>()
|
||||||
|
|
||||||
|
for (i in 0..<numberOfKeys) {
|
||||||
|
val keySize = buffer.int
|
||||||
|
|
||||||
|
val key = ByteArray(keySize)
|
||||||
|
buffer.get(key)
|
||||||
|
|
||||||
|
keys.add(Node.Key(key.toUByteArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
val numberOfDatas = buffer.int
|
||||||
|
val datas = mutableListOf<Data>()
|
||||||
|
|
||||||
|
for (i in 0..<numberOfDatas) {
|
||||||
|
val offset = buffer.long
|
||||||
|
val length = buffer.int
|
||||||
|
|
||||||
|
datas.add(Data(offset, length))
|
||||||
|
}
|
||||||
|
|
||||||
|
val numberOfSubNodeAddresses = B+1
|
||||||
|
val subNodeAddresses = mutableListOf<Long>()
|
||||||
|
|
||||||
|
for (i in 0..<numberOfSubNodeAddresses) {
|
||||||
|
val subNodeAddress = buffer.long
|
||||||
|
subNodeAddresses.add(subNodeAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Node(keys, datas, subNodeAddresses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val isLeaf: Boolean = subNodeAddresses.all { it == 0L }
|
||||||
|
|
||||||
|
fun locateKey(target: Key): Pair<Boolean, Int> {
|
||||||
|
val index = keys.indexOfFirst { key -> target <= key }
|
||||||
|
|
||||||
|
if (index == -1) {
|
||||||
|
return Pair(false, keys.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Pair(keys[index] == target, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
80
app/src/main/java/xyz/quaver/pupil/networking/SSL.kt
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package xyz.quaver.pupil.networking
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.security.cert.CertificateFactory
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.TrustManagerFactory
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
const val ISRG_ROOT_X1 = """-----BEGIN CERTIFICATE-----
|
||||||
|
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
|
||||||
|
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||||
|
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
|
||||||
|
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
|
||||||
|
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
|
||||||
|
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
|
||||||
|
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
|
||||||
|
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
|
||||||
|
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
|
||||||
|
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
|
||||||
|
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
|
||||||
|
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
|
||||||
|
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
|
||||||
|
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
|
||||||
|
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
|
||||||
|
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
|
||||||
|
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
|
||||||
|
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
|
||||||
|
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
|
||||||
|
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
|
||||||
|
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
|
||||||
|
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
|
||||||
|
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
|
||||||
|
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
|
||||||
|
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
|
||||||
|
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
|
||||||
|
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
|
||||||
|
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
|
||||||
|
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
|
||||||
|
-----END CERTIFICATE-----"""
|
||||||
|
|
||||||
|
object SSLSettings {
|
||||||
|
val keyStore: KeyStore by lazy {
|
||||||
|
KeyStore.getInstance(KeyStore.getDefaultType()).apply {
|
||||||
|
load(null, null)
|
||||||
|
|
||||||
|
val certificateFactory = CertificateFactory.getInstance("X.509")
|
||||||
|
val certificate = certificateFactory.generateCertificate(ISRG_ROOT_X1.byteInputStream())
|
||||||
|
|
||||||
|
setCertificateEntry("isrgrootx1", certificate)
|
||||||
|
|
||||||
|
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply {
|
||||||
|
init(null as KeyStore?)
|
||||||
|
trustManagers.filterIsInstance<X509TrustManager>().forEach { trustManager ->
|
||||||
|
trustManager.acceptedIssuers.forEach { acceptedIssuer ->
|
||||||
|
setCertificateEntry(acceptedIssuer.subjectDN.name, acceptedIssuer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val trustManagerFactory: TrustManagerFactory? by lazy {
|
||||||
|
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply {
|
||||||
|
init(keyStore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val sslContext: SSLContext? by lazy {
|
||||||
|
SSLContext.getInstance("TLS").apply {
|
||||||
|
init(null, trustManagerFactory?.trustManagers, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val trustManager: X509TrustManager? by lazy {
|
||||||
|
trustManagerFactory?.trustManagers?.filterIsInstance<X509TrustManager>()?.firstOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
90
app/src/main/java/xyz/quaver/pupil/networking/SearchQuery.kt
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package xyz.quaver.pupil.networking
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
|
||||||
|
val validNamespace = listOf(
|
||||||
|
"female",
|
||||||
|
"male",
|
||||||
|
"artist",
|
||||||
|
"group",
|
||||||
|
"character",
|
||||||
|
"series",
|
||||||
|
"type",
|
||||||
|
"language",
|
||||||
|
"tag"
|
||||||
|
)
|
||||||
|
|
||||||
|
class SearchQueryPreviewParameterProvider: PreviewParameterProvider<SearchQuery> {
|
||||||
|
override val values = sequenceOf(
|
||||||
|
SearchQuery.And(listOf(
|
||||||
|
SearchQuery.Or(listOf(
|
||||||
|
SearchQuery.And(listOf(
|
||||||
|
SearchQuery.Tag("language", "thisisareallylongtagyoucantevenseetheendofthis"),
|
||||||
|
SearchQuery.Tag("language", "korean"),
|
||||||
|
SearchQuery.Tag("female", "unusual pupil"),
|
||||||
|
SearchQuery.Tag("female", "collar")
|
||||||
|
)),
|
||||||
|
SearchQuery.And(listOf(
|
||||||
|
SearchQuery.Tag("language", "japanese"),
|
||||||
|
SearchQuery.Tag("female", "unusual pupil"),
|
||||||
|
SearchQuery.Tag("female", "collar")
|
||||||
|
))
|
||||||
|
)),
|
||||||
|
SearchQuery.Not(
|
||||||
|
SearchQuery.And(listOf(
|
||||||
|
SearchQuery.Tag("male", "yaoi"),
|
||||||
|
SearchQuery.Tag("group", "zenmai kourogi")
|
||||||
|
))
|
||||||
|
)
|
||||||
|
))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface SearchQuery {
|
||||||
|
data class Tag(
|
||||||
|
val namespace: String? = null,
|
||||||
|
val tag: String
|
||||||
|
): SearchQuery, TagLike {
|
||||||
|
companion object {
|
||||||
|
fun parseTag(tag: String): Tag {
|
||||||
|
val splitTag = tag.split(':', limit = 1)
|
||||||
|
|
||||||
|
return if (splitTag.size == 1) {
|
||||||
|
Tag(null, tag)
|
||||||
|
} else {
|
||||||
|
Tag(splitTag[0], splitTag[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString() = if (namespace == null) tag else "$namespace:$tag"
|
||||||
|
|
||||||
|
override fun toTag() = this
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
data class And(
|
||||||
|
val queries: List<SearchQuery>
|
||||||
|
): SearchQuery {
|
||||||
|
init {
|
||||||
|
if (queries.isEmpty()) {
|
||||||
|
error("queries cannot be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Or(
|
||||||
|
val queries: List<SearchQuery>
|
||||||
|
): SearchQuery {
|
||||||
|
init {
|
||||||
|
if (queries.isEmpty()) {
|
||||||
|
error("queries cannot be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Not(
|
||||||
|
val query: SearchQuery
|
||||||
|
): SearchQuery
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2020 tom5079
|
||||||
|
*
|
||||||
|
* This program 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.
|
||||||
|
*
|
||||||
|
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.services
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.job.JobParameters
|
||||||
|
import android.app.job.JobService
|
||||||
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import io.ktor.util.cio.writeChannel
|
||||||
|
import io.ktor.util.collections.ConcurrentMap
|
||||||
|
import io.ktor.util.collections.ConcurrentSet
|
||||||
|
import io.ktor.utils.io.copyAndClose
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import xyz.quaver.pupil.Pupil
|
||||||
|
import xyz.quaver.pupil.networking.GalleryFile
|
||||||
|
import xyz.quaver.pupil.networking.HitomiHttpClient
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@SuppressLint("SpecifyJobSchedulerIdRange")
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class ImageCacheService : JobService() {
|
||||||
|
override fun onStartJob(params: JobParameters?): Boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStopJob(params: JobParameters?): Boolean {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/src/main/java/xyz/quaver/pupil/types/SendLogException.kt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2022 tom5079
|
||||||
|
*
|
||||||
|
* This program 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.
|
||||||
|
*
|
||||||
|
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package xyz.quaver.pupil.types
|
||||||
|
|
||||||
|
class SendLogException : Exception()
|
||||||
|
class JavascriptException(message: String?) : Exception(message)
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package xyz.quaver.pupil.types
|
|
||||||
|
|
||||||
import com.arlib.floatingsearchview.suggestions.model.SearchSuggestion
|
|
||||||
import kotlinx.android.parcel.Parcelize
|
|
||||||
import xyz.quaver.hitomi.Suggestion
|
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data class TagSuggestion constructor(val s: String, val t: Int, val u: String, val n: String) : SearchSuggestion {
|
|
||||||
constructor(s: Suggestion) : this(s.s, s.t, s.u, s.n)
|
|
||||||
|
|
||||||
override fun getBody(): String {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,21 @@
|
|||||||
|
/*
|
||||||
|
* Pupil, Hitomi.la viewer for Android
|
||||||
|
* Copyright (C) 2019 tom5079
|
||||||
|
*
|
||||||
|
* This program 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.
|
||||||
|
*
|
||||||
|
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
package xyz.quaver.pupil.types
|
package xyz.quaver.pupil.types
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
@@ -6,7 +24,7 @@ import kotlinx.serialization.Serializable
|
|||||||
data class Tag(val area: String?, val tag: String, val isNegative: Boolean = false) {
|
data class Tag(val area: String?, val tag: String, val isNegative: Boolean = false) {
|
||||||
companion object {
|
companion object {
|
||||||
fun parse(tag: String) : Tag {
|
fun parse(tag: String) : Tag {
|
||||||
if (tag.first() == '-') {
|
if (tag.firstOrNull() == '-') {
|
||||||
tag.substring(1).split(Regex(":"), 2).let {
|
tag.substring(1).split(Regex(":"), 2).let {
|
||||||
return when(it.size) {
|
return when(it.size) {
|
||||||
2 -> Tag(it[0], it[1], true)
|
2 -> Tag(it[0], it[1], true)
|
||||||
@@ -44,12 +62,10 @@ data class Tag(val area: String?, val tag: String, val isNegative: Boolean = fal
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
override fun hashCode() = toString().hashCode()
|
||||||
return super.hashCode()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Tags(tag: List<Tag?>?) : ArrayList<Tag>() {
|
class Tags(val tags: MutableSet<Tag> = mutableSetOf()) : MutableSet<Tag> by tags {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun parse(tags: String) : Tags {
|
fun parse(tags: String) : Tags {
|
||||||
@@ -59,20 +75,13 @@ class Tags(tag: List<Tag?>?) : ArrayList<Tag>() {
|
|||||||
Tag.parse(it)
|
Tag.parse(it)
|
||||||
else
|
else
|
||||||
null
|
null
|
||||||
}
|
}.filterNotNull().toMutableSet()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
|
||||||
tag?.forEach {
|
|
||||||
if (it != null)
|
|
||||||
add(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun contains(element: String): Boolean {
|
fun contains(element: String): Boolean {
|
||||||
forEach {
|
tags.forEach {
|
||||||
if (it.toString() == element)
|
if (it.toString() == element)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -81,23 +90,22 @@ class Tags(tag: List<Tag?>?) : ArrayList<Tag>() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun add(element: String): Boolean {
|
fun add(element: String): Boolean {
|
||||||
return super.add(Tag.parse(element))
|
return tags.add(Tag.parse(element))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(element: String) {
|
fun remove(element: String) {
|
||||||
filter { it.toString() == element }.forEach {
|
tags.filter { it.toString() == element }.forEach {
|
||||||
remove(it)
|
tags.remove(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeByArea(area: String) {
|
fun removeByArea(area: String, isNegative: Boolean? = null) {
|
||||||
filter { it.area == area }.forEach {
|
tags.filter { it.area == area && (if(isNegative == null) true else (it.isNegative == isNegative)) }.forEach {
|
||||||
remove(it)
|
tags.remove(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun toString(): String {
|
override fun toString(): String {
|
||||||
return joinToString(" ") { it.toString() }
|
return tags.joinToString(" ") { it.toString() }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package xyz.quaver.pupil.ui
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import com.andrognito.patternlockview.PatternLockView
|
|
||||||
import com.google.android.material.snackbar.Snackbar
|
|
||||||
import kotlinx.android.synthetic.main.activity_lock.*
|
|
||||||
import kotlinx.android.synthetic.main.fragment_pattern_lock.*
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.util.Lock
|
|
||||||
import xyz.quaver.pupil.util.LockManager
|
|
||||||
|
|
||||||
class LockActivity : AppCompatActivity() {
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(R.layout.activity_lock)
|
|
||||||
|
|
||||||
val lockManager = LockManager(this)
|
|
||||||
|
|
||||||
val mode = intent.getStringExtra("mode")
|
|
||||||
|
|
||||||
lock_pattern.isEnabled = false
|
|
||||||
lock_pin.isEnabled = false
|
|
||||||
lock_fingerprint.isEnabled = false
|
|
||||||
lock_password.isEnabled = false
|
|
||||||
|
|
||||||
when(mode) {
|
|
||||||
null -> {
|
|
||||||
if (lockManager.empty()) {
|
|
||||||
setResult(RESULT_OK)
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"add_lock" -> {
|
|
||||||
when(intent.getStringExtra("type")!!) {
|
|
||||||
"pattern" -> {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
supportFragmentManager.beginTransaction().add(
|
|
||||||
R.id.lock_content,
|
|
||||||
PatternLockFragment().apply {
|
|
||||||
var lastPass = ""
|
|
||||||
onPatternDrawn = {
|
|
||||||
when(mode) {
|
|
||||||
null -> {
|
|
||||||
val result = lockManager.check(it)
|
|
||||||
|
|
||||||
if (result == true) {
|
|
||||||
setResult(Activity.RESULT_OK)
|
|
||||||
finish()
|
|
||||||
} else
|
|
||||||
lock_pattern_view.setViewMode(PatternLockView.PatternViewMode.WRONG)
|
|
||||||
}
|
|
||||||
"add_lock" -> {
|
|
||||||
if (lastPass.isEmpty()) {
|
|
||||||
lastPass = it
|
|
||||||
|
|
||||||
Snackbar.make(view!!, R.string.settings_lock_confirm, Snackbar.LENGTH_LONG).show()
|
|
||||||
} else {
|
|
||||||
if (lastPass == it) {
|
|
||||||
LockManager(context!!).add(Lock.generate(Lock.Type.PATTERN, it))
|
|
||||||
finish()
|
|
||||||
} else {
|
|
||||||
lock_pattern_view.setViewMode(PatternLockView.PatternViewMode.WRONG)
|
|
||||||
lastPass = ""
|
|
||||||
|
|
||||||
Snackbar.make(view!!, R.string.settings_lock_wrong_confirm, Snackbar.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
).commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package xyz.quaver.pupil.ui
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import com.andrognito.patternlockview.PatternLockView
|
|
||||||
import com.andrognito.patternlockview.listener.PatternLockViewListener
|
|
||||||
import com.andrognito.patternlockview.utils.PatternLockUtils
|
|
||||||
import kotlinx.android.synthetic.main.fragment_pattern_lock.*
|
|
||||||
import kotlinx.android.synthetic.main.fragment_pattern_lock.view.*
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.util.hash
|
|
||||||
import xyz.quaver.pupil.util.hashWithSalt
|
|
||||||
|
|
||||||
class PatternLockFragment : Fragment(), PatternLockViewListener {
|
|
||||||
|
|
||||||
var onPatternDrawn: ((String) -> Unit)? = null
|
|
||||||
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater, container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
): View? {
|
|
||||||
return inflater.inflate(R.layout.fragment_pattern_lock, container, false).apply {
|
|
||||||
lock_pattern_view.addPatternLockListener(this@PatternLockFragment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onComplete(pattern: MutableList<PatternLockView.Dot>?) {
|
|
||||||
val password = PatternLockUtils.patternToMD5(lock_pattern_view, pattern)
|
|
||||||
onPatternDrawn?.invoke(password)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onProgress(progressPattern: MutableList<PatternLockView.Dot>?) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStarted() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,386 +0,0 @@
|
|||||||
package xyz.quaver.pupil.ui
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.graphics.drawable.Animatable
|
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.*
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.PagerSnapHelper
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import androidx.vectordrawable.graphics.drawable.Animatable2Compat
|
|
||||||
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
|
|
||||||
import com.crashlytics.android.Crashlytics
|
|
||||||
import com.google.android.material.snackbar.Snackbar
|
|
||||||
import kotlinx.android.synthetic.main.activity_reader.*
|
|
||||||
import kotlinx.android.synthetic.main.activity_reader.view.*
|
|
||||||
import kotlinx.android.synthetic.main.dialog_numberpicker.view.*
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.io.IOException
|
|
||||||
import kotlinx.serialization.ImplicitReflectionSerializer
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonConfiguration
|
|
||||||
import xyz.quaver.hitomi.GalleryBlock
|
|
||||||
import xyz.quaver.hitomi.getGalleryBlock
|
|
||||||
import xyz.quaver.pupil.Pupil
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.adapters.ReaderAdapter
|
|
||||||
import xyz.quaver.pupil.util.GalleryDownloader
|
|
||||||
import xyz.quaver.pupil.util.Histories
|
|
||||||
import xyz.quaver.pupil.util.ItemClickSupport
|
|
||||||
|
|
||||||
class ReaderActivity : AppCompatActivity() {
|
|
||||||
|
|
||||||
private val images = ArrayList<String>()
|
|
||||||
private lateinit var galleryBlock: GalleryBlock
|
|
||||||
private var gallerySize = 0
|
|
||||||
private var currentPage = 0
|
|
||||||
|
|
||||||
private var isScroll = true
|
|
||||||
private var isFullscreen = false
|
|
||||||
set(value) {
|
|
||||||
field = value
|
|
||||||
|
|
||||||
(reader_recyclerview.adapter as ReaderAdapter).isFullScreen = value
|
|
||||||
|
|
||||||
reader_progressbar.visibility = when {
|
|
||||||
value -> View.VISIBLE
|
|
||||||
else -> View.GONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private lateinit var downloader: GalleryDownloader
|
|
||||||
|
|
||||||
private val snapHelper = PagerSnapHelper()
|
|
||||||
|
|
||||||
private var menu: Menu? = null
|
|
||||||
|
|
||||||
private lateinit var favorites: Histories
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
favorites = (application as Pupil).favorites
|
|
||||||
|
|
||||||
window.setFlags(
|
|
||||||
WindowManager.LayoutParams.FLAG_SECURE,
|
|
||||||
WindowManager.LayoutParams.FLAG_SECURE)
|
|
||||||
|
|
||||||
setContentView(R.layout.activity_reader)
|
|
||||||
|
|
||||||
handleIntent(intent)
|
|
||||||
|
|
||||||
Crashlytics.setInt("GalleryID", galleryBlock.id)
|
|
||||||
|
|
||||||
if (!::galleryBlock.isInitialized) {
|
|
||||||
onBackPressed()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
supportActionBar?.title = galleryBlock.title
|
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
|
||||||
|
|
||||||
initDownloader()
|
|
||||||
|
|
||||||
initView()
|
|
||||||
|
|
||||||
if (!downloader.download)
|
|
||||||
downloader.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
|
||||||
super.onNewIntent(intent)
|
|
||||||
handleIntent(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleIntent(intent: Intent) {
|
|
||||||
if (intent.action == Intent.ACTION_VIEW) {
|
|
||||||
val uri = intent.data
|
|
||||||
val lastPathSegment = uri?.lastPathSegment
|
|
||||||
if (uri != null && lastPathSegment != null) {
|
|
||||||
val nonNumber = Regex("[^-?0-9]+")
|
|
||||||
|
|
||||||
val galleryID = when (uri.host) {
|
|
||||||
"hitomi.la" -> lastPathSegment.replace(nonNumber, "").toInt()
|
|
||||||
"히요비.asia" -> lastPathSegment.toInt()
|
|
||||||
"xn--9w3b15m8vo.asia" -> lastPathSegment.toInt()
|
|
||||||
"e-hentai.org" -> uri.pathSegments[1].toInt()
|
|
||||||
else -> return
|
|
||||||
}
|
|
||||||
|
|
||||||
runBlocking {
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
galleryBlock = getGalleryBlock(galleryID) ?: return@launch
|
|
||||||
}.join()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
galleryBlock = Json(JsonConfiguration.Stable).parse(
|
|
||||||
GalleryBlock.serializer(),
|
|
||||||
intent.getStringExtra("galleryblock")!!
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
|
|
||||||
|
|
||||||
if (preferences.getBoolean("security_mode", false))
|
|
||||||
window.setFlags(
|
|
||||||
WindowManager.LayoutParams.FLAG_SECURE,
|
|
||||||
WindowManager.LayoutParams.FLAG_SECURE)
|
|
||||||
else
|
|
||||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
|
||||||
|
|
||||||
super.onResume()
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseExperimental(ImplicitReflectionSerializer::class)
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
|
||||||
menuInflater.inflate(R.menu.reader, menu)
|
|
||||||
|
|
||||||
with(menu?.findItem(R.id.reader_menu_favorite)) {
|
|
||||||
this ?: return@with
|
|
||||||
|
|
||||||
if (favorites.contains(galleryBlock.id))
|
|
||||||
(icon as Animatable).start()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.menu = menu
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem?): Boolean {
|
|
||||||
when(item?.itemId) {
|
|
||||||
R.id.reader_menu_page_indicator -> {
|
|
||||||
val view = LayoutInflater.from(this).inflate(R.layout.dialog_numberpicker, findViewById(android.R.id.content), false)
|
|
||||||
with(view.dialog_number_picker) {
|
|
||||||
minValue=1
|
|
||||||
maxValue=gallerySize
|
|
||||||
value=currentPage
|
|
||||||
}
|
|
||||||
val dialog = AlertDialog.Builder(this).apply {
|
|
||||||
setView(view)
|
|
||||||
}.create()
|
|
||||||
view.dialog_ok.setOnClickListener {
|
|
||||||
(reader_recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPositionWithOffset(view.dialog_number_picker.value-1, 0)
|
|
||||||
dialog.dismiss()
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog.show()
|
|
||||||
}
|
|
||||||
R.id.reader_menu_favorite -> {
|
|
||||||
val id = galleryBlock.id
|
|
||||||
val favorite = menu?.findItem(R.id.reader_menu_favorite) ?: return true
|
|
||||||
|
|
||||||
if (favorites.contains(id)) {
|
|
||||||
favorites.remove(id)
|
|
||||||
favorite.icon = AnimatedVectorDrawableCompat.create(this, R.drawable.avd_star)
|
|
||||||
} else {
|
|
||||||
favorites.add(id)
|
|
||||||
(favorite.icon as Animatable).start()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
|
|
||||||
if (::downloader.isInitialized && !downloader.download)
|
|
||||||
downloader.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBackPressed() {
|
|
||||||
if (isScroll and !isFullscreen)
|
|
||||||
super.onBackPressed()
|
|
||||||
|
|
||||||
if (isFullscreen) {
|
|
||||||
isFullscreen = false
|
|
||||||
fullscreen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isScroll) {
|
|
||||||
isScroll = true
|
|
||||||
scrollMode(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initDownloader() {
|
|
||||||
var d: GalleryDownloader? = GalleryDownloader.get(galleryBlock.id)
|
|
||||||
|
|
||||||
if (d == null) {
|
|
||||||
try {
|
|
||||||
d = GalleryDownloader(this, galleryBlock)
|
|
||||||
} catch (e: IOException) {
|
|
||||||
Snackbar.make(reader_layout, R.string.unable_to_connect, Snackbar.LENGTH_LONG).show()
|
|
||||||
finish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
downloader = d.apply {
|
|
||||||
onReaderLoadedHandler = {
|
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
|
||||||
with(reader_download_progressbar) {
|
|
||||||
max = it.size
|
|
||||||
progress = 0
|
|
||||||
}
|
|
||||||
with(reader_progressbar) {
|
|
||||||
max = it.size
|
|
||||||
progress = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
gallerySize = it.size
|
|
||||||
menu?.findItem(R.id.reader_menu_page_indicator)?.title = "$currentPage/${it.size}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onProgressHandler = {
|
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
|
||||||
reader_download_progressbar.progress = it
|
|
||||||
menu?.findItem(R.id.reader_menu_use_hiyobi)?.isVisible = downloader.useHiyobi
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onDownloadedHandler = {
|
|
||||||
val item = it.toList()
|
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
|
||||||
if (images.isEmpty()) {
|
|
||||||
images.addAll(item)
|
|
||||||
reader_recyclerview.adapter?.notifyDataSetChanged()
|
|
||||||
} else {
|
|
||||||
images.add(item.last())
|
|
||||||
reader_recyclerview.adapter?.notifyItemInserted(images.size-1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onErrorHandler = {
|
|
||||||
if (it is IOException)
|
|
||||||
Snackbar.make(reader_layout, R.string.unable_to_connect, Snackbar.LENGTH_LONG).show()
|
|
||||||
downloader.download = false
|
|
||||||
}
|
|
||||||
onCompleteHandler = {
|
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
|
||||||
reader_download_progressbar.visibility = View.GONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onNotifyChangedHandler = { notify ->
|
|
||||||
val fab = reader_fab_download
|
|
||||||
|
|
||||||
runOnUiThread {
|
|
||||||
if (notify) {
|
|
||||||
val icon = AnimatedVectorDrawableCompat.create(this, R.drawable.ic_downloading)
|
|
||||||
icon?.registerAnimationCallback(object: Animatable2Compat.AnimationCallback() {
|
|
||||||
override fun onAnimationEnd(drawable: Drawable?) {
|
|
||||||
if (downloader.download)
|
|
||||||
fab.post {
|
|
||||||
icon.start()
|
|
||||||
fab.labelText = getString(R.string.reader_fab_download_cancel)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
fab.post {
|
|
||||||
fab.setImageResource(R.drawable.ic_download)
|
|
||||||
fab.labelText = getString(R.string.reader_fab_download)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
fab.setImageDrawable(icon)
|
|
||||||
icon?.start()
|
|
||||||
} else {
|
|
||||||
runOnUiThread {
|
|
||||||
fab.setImageResource(R.drawable.ic_download)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (downloader.download) {
|
|
||||||
downloader.invokeOnReaderLoaded()
|
|
||||||
downloader.invokeOnNotifyChanged()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initView() {
|
|
||||||
with(reader_recyclerview) {
|
|
||||||
adapter = ReaderAdapter(images)
|
|
||||||
|
|
||||||
addOnScrollListener(object: RecyclerView.OnScrollListener() {
|
|
||||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
|
||||||
super.onScrolled(recyclerView, dx, dy)
|
|
||||||
|
|
||||||
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
|
|
||||||
|
|
||||||
if (layoutManager.findFirstVisibleItemPosition() == -1)
|
|
||||||
return
|
|
||||||
currentPage = layoutManager.findFirstVisibleItemPosition()+1
|
|
||||||
menu?.findItem(R.id.reader_menu_page_indicator)?.title = "$currentPage/$gallerySize"
|
|
||||||
this@ReaderActivity.reader_progressbar.progress = currentPage
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
ItemClickSupport.addTo(this)
|
|
||||||
.setOnItemClickListener { _, _, _ ->
|
|
||||||
if (isScroll) {
|
|
||||||
isScroll = false
|
|
||||||
isFullscreen = true
|
|
||||||
|
|
||||||
scrollMode(false)
|
|
||||||
fullscreen(true)
|
|
||||||
} else {
|
|
||||||
(reader_recyclerview.layoutManager as LinearLayoutManager?)?.scrollToPosition(currentPage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reader_fab_fullscreen.setOnClickListener {
|
|
||||||
isFullscreen = true
|
|
||||||
fullscreen(isFullscreen)
|
|
||||||
|
|
||||||
reader_fab.close(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
reader_fab_download.setOnClickListener {
|
|
||||||
downloader.download = !downloader.download
|
|
||||||
|
|
||||||
if (!downloader.download)
|
|
||||||
downloader.clearNotification()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fullscreen(isFullscreen: Boolean) {
|
|
||||||
with(window.attributes) {
|
|
||||||
if (isFullscreen) {
|
|
||||||
flags = flags or WindowManager.LayoutParams.FLAG_FULLSCREEN
|
|
||||||
supportActionBar?.hide()
|
|
||||||
this@ReaderActivity.reader_fab.visibility = View.INVISIBLE
|
|
||||||
} else {
|
|
||||||
flags = flags and WindowManager.LayoutParams.FLAG_FULLSCREEN.inv()
|
|
||||||
supportActionBar?.show()
|
|
||||||
this@ReaderActivity.reader_fab.visibility = View.VISIBLE
|
|
||||||
}
|
|
||||||
|
|
||||||
window.attributes = this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scrollMode(isScroll: Boolean) {
|
|
||||||
if (isScroll) {
|
|
||||||
snapHelper.attachToRecyclerView(null)
|
|
||||||
reader_recyclerview.layoutManager = LinearLayoutManager(this)
|
|
||||||
} else {
|
|
||||||
snapHelper.attachToRecyclerView(reader_recyclerview)
|
|
||||||
reader_recyclerview.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
(reader_recyclerview.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(currentPage-1, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,400 +0,0 @@
|
|||||||
package xyz.quaver.pupil.ui
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.text.Editable
|
|
||||||
import android.text.TextWatcher
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.MenuItem
|
|
||||||
import android.view.WindowManager
|
|
||||||
import android.widget.ArrayAdapter
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import android.widget.TextView
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.preference.Preference
|
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import kotlinx.android.synthetic.main.dialog_default_query.view.*
|
|
||||||
import xyz.quaver.pupil.Pupil
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.types.Tags
|
|
||||||
import xyz.quaver.pupil.util.Lock
|
|
||||||
import xyz.quaver.pupil.util.LockManager
|
|
||||||
import xyz.quaver.pupil.util.getDownloadDirectory
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class SettingsActivity : AppCompatActivity() {
|
|
||||||
|
|
||||||
val REQUEST_LOCK = 38238
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
window.setFlags(
|
|
||||||
WindowManager.LayoutParams.FLAG_SECURE,
|
|
||||||
WindowManager.LayoutParams.FLAG_SECURE)
|
|
||||||
|
|
||||||
setContentView(R.layout.settings_activity)
|
|
||||||
supportFragmentManager
|
|
||||||
.beginTransaction()
|
|
||||||
.replace(R.id.settings, SettingsFragment())
|
|
||||||
.commit()
|
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
val preferences = PreferenceManager.getDefaultSharedPreferences(this)
|
|
||||||
|
|
||||||
if (preferences.getBoolean("security_mode", false))
|
|
||||||
window.setFlags(
|
|
||||||
WindowManager.LayoutParams.FLAG_SECURE,
|
|
||||||
WindowManager.LayoutParams.FLAG_SECURE)
|
|
||||||
else
|
|
||||||
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
|
||||||
super.onResume()
|
|
||||||
}
|
|
||||||
|
|
||||||
class SettingsFragment : PreferenceFragmentCompat() {
|
|
||||||
|
|
||||||
private val suffix = listOf(
|
|
||||||
"B",
|
|
||||||
"kB",
|
|
||||||
"MB",
|
|
||||||
"GB",
|
|
||||||
"TB" //really?
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
|
|
||||||
val lockManager = LockManager(context!!)
|
|
||||||
|
|
||||||
findPreference<Preference>("app_lock")?.summary = if (lockManager.locks.isNullOrEmpty()) {
|
|
||||||
getString(R.string.settings_lock_none)
|
|
||||||
} else {
|
|
||||||
lockManager.locks?.joinToString(", ") {
|
|
||||||
when(it.type) {
|
|
||||||
Lock.Type.PATTERN -> getString(R.string.settings_lock_pattern)
|
|
||||||
Lock.Type.PIN -> getString(R.string.settings_lock_pin)
|
|
||||||
Lock.Type.PASSWORD -> getString(R.string.settings_lock_password)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getDirSize(dir: File) : String {
|
|
||||||
var size = dir.walk().map { it.length() }.sum()
|
|
||||||
var suffixIndex = 0
|
|
||||||
|
|
||||||
while (size >= 1024) {
|
|
||||||
size /= 1024
|
|
||||||
suffixIndex++
|
|
||||||
}
|
|
||||||
|
|
||||||
return getString(R.string.settings_clear_summary, size, suffix[suffixIndex])
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
|
||||||
setPreferencesFromResource(R.xml.root_preferences, rootKey)
|
|
||||||
|
|
||||||
with(findPreference<Preference>("app_version")) {
|
|
||||||
this!!
|
|
||||||
|
|
||||||
val manager = context.packageManager
|
|
||||||
val info = manager.getPackageInfo(context.packageName, 0)
|
|
||||||
|
|
||||||
summary = info.versionName
|
|
||||||
}
|
|
||||||
|
|
||||||
with(findPreference<Preference>("delete_cache")) {
|
|
||||||
this!!
|
|
||||||
|
|
||||||
val dir = File(context.cacheDir, "imageCache")
|
|
||||||
|
|
||||||
summary = getDirSize(dir)
|
|
||||||
|
|
||||||
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
|
||||||
AlertDialog.Builder(context).apply {
|
|
||||||
setTitle(R.string.warning)
|
|
||||||
setMessage(R.string.settings_clear_cache_alert_message)
|
|
||||||
setPositiveButton(android.R.string.yes) { _, _ ->
|
|
||||||
if (dir.exists())
|
|
||||||
dir.deleteRecursively()
|
|
||||||
|
|
||||||
summary = getDirSize(dir)
|
|
||||||
}
|
|
||||||
setNegativeButton(android.R.string.no) { _, _ -> }
|
|
||||||
}.show()
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
with(findPreference<Preference>("delete_downloads")) {
|
|
||||||
this!!
|
|
||||||
|
|
||||||
val dir = getDownloadDirectory(context)!!
|
|
||||||
|
|
||||||
summary = getDirSize(dir)
|
|
||||||
|
|
||||||
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
|
||||||
AlertDialog.Builder(context).apply {
|
|
||||||
setTitle(R.string.warning)
|
|
||||||
setMessage(R.string.settings_clear_downloads_alert_message)
|
|
||||||
setPositiveButton(android.R.string.yes) { _, _ ->
|
|
||||||
if (dir.exists())
|
|
||||||
dir.deleteRecursively()
|
|
||||||
|
|
||||||
val downloads = (activity!!.application as Pupil).downloads
|
|
||||||
|
|
||||||
downloads.clear()
|
|
||||||
|
|
||||||
summary = getDirSize(dir)
|
|
||||||
}
|
|
||||||
setNegativeButton(android.R.string.no) { _, _ -> }
|
|
||||||
}.show()
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
with(findPreference<Preference>("clear_history")) {
|
|
||||||
this!!
|
|
||||||
|
|
||||||
val histories = (activity!!.application as Pupil).histories
|
|
||||||
|
|
||||||
summary = getString(R.string.settings_clear_history_summary, histories.size)
|
|
||||||
|
|
||||||
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
|
||||||
AlertDialog.Builder(context).apply {
|
|
||||||
setTitle(R.string.warning)
|
|
||||||
setMessage(R.string.settings_clear_history_alert_message)
|
|
||||||
setPositiveButton(android.R.string.yes) { _, _ ->
|
|
||||||
histories.clear()
|
|
||||||
summary = getString(R.string.settings_clear_history_summary, histories.size)
|
|
||||||
}
|
|
||||||
setNegativeButton(android.R.string.no) { _, _ -> }
|
|
||||||
}.show()
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
with(findPreference<Preference>("default_query")) {
|
|
||||||
this!!
|
|
||||||
|
|
||||||
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
|
|
||||||
|
|
||||||
summary = preferences.getString("default_query", "") ?: ""
|
|
||||||
|
|
||||||
val languages = resources.getStringArray(R.array.languages).map {
|
|
||||||
it.split("|").let { split ->
|
|
||||||
Pair(split[0], split[1])
|
|
||||||
}
|
|
||||||
}.toMap()
|
|
||||||
val reverseLanguages = languages.entries.associate { (k, v) -> v to k }
|
|
||||||
|
|
||||||
val excludeBL = "-male:yaoi"
|
|
||||||
val excludeGuro = listOf("-female:guro", "-male:guro")
|
|
||||||
|
|
||||||
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
|
||||||
val dialogView = LayoutInflater.from(context).inflate(
|
|
||||||
R.layout.dialog_default_query,
|
|
||||||
LinearLayout(context),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
|
|
||||||
val tags = Tags.parse(
|
|
||||||
preferences.getString("default_query", "") ?: ""
|
|
||||||
)
|
|
||||||
|
|
||||||
summary = tags.toString()
|
|
||||||
|
|
||||||
with(dialogView.default_query_dialog_language_selector) {
|
|
||||||
adapter =
|
|
||||||
ArrayAdapter(
|
|
||||||
context,
|
|
||||||
android.R.layout.simple_spinner_dropdown_item,
|
|
||||||
arrayListOf(
|
|
||||||
getString(R.string.default_query_dialog_language_selector_none)
|
|
||||||
).apply {
|
|
||||||
addAll(languages.values)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (tags.any { it.area == "language" }) {
|
|
||||||
val tag = languages[tags.first { it.area == "language" }.tag]
|
|
||||||
if (tag != null) {
|
|
||||||
setSelection(
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
(adapter as ArrayAdapter<String>).getPosition(tag)
|
|
||||||
)
|
|
||||||
tags.removeByArea("language")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
with(dialogView.default_query_dialog_BL_checkbox) {
|
|
||||||
isChecked = tags.contains(excludeBL)
|
|
||||||
if (tags.contains(excludeBL))
|
|
||||||
tags.remove(excludeBL)
|
|
||||||
}
|
|
||||||
|
|
||||||
with(dialogView.default_query_dialog_guro_checkbox) {
|
|
||||||
isChecked = excludeGuro.all { tags.contains(it) }
|
|
||||||
if (excludeGuro.all { tags.contains(it) })
|
|
||||||
excludeGuro.forEach {
|
|
||||||
tags.remove(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
with(dialogView.default_query_dialog_edittext) {
|
|
||||||
setText(tags.toString(), TextView.BufferType.EDITABLE)
|
|
||||||
addTextChangedListener(object : TextWatcher {
|
|
||||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
|
||||||
|
|
||||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
|
||||||
|
|
||||||
override fun afterTextChanged(s: Editable?) {
|
|
||||||
s ?: return
|
|
||||||
|
|
||||||
if (s.any { it.isUpperCase() })
|
|
||||||
s.replace(0, s.length, s.toString().toLowerCase())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
val dialog = AlertDialog.Builder(context!!).apply {
|
|
||||||
setView(dialogView)
|
|
||||||
}.create()
|
|
||||||
|
|
||||||
dialogView.default_query_dialog_ok.setOnClickListener {
|
|
||||||
val newTags = Tags.parse(dialogView.default_query_dialog_edittext.text.toString())
|
|
||||||
|
|
||||||
with(dialogView.default_query_dialog_language_selector) {
|
|
||||||
if (selectedItemPosition != 0)
|
|
||||||
newTags.add("language:${reverseLanguages[selectedItem]}")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dialogView.default_query_dialog_BL_checkbox.isChecked)
|
|
||||||
newTags.add(excludeBL)
|
|
||||||
|
|
||||||
if (dialogView.default_query_dialog_guro_checkbox.isChecked)
|
|
||||||
excludeGuro.forEach { tag ->
|
|
||||||
newTags.add(tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
preferenceManager.sharedPreferences.edit().putString("default_query", newTags.toString()).apply()
|
|
||||||
summary = preferences.getString("default_query", "") ?: ""
|
|
||||||
tags.clear()
|
|
||||||
tags.addAll(newTags)
|
|
||||||
dialog.dismiss()
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog.show()
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
with(findPreference<Preference>("app_lock")) {
|
|
||||||
this!!
|
|
||||||
|
|
||||||
val lockManager = LockManager(context)
|
|
||||||
|
|
||||||
summary = if (lockManager.locks.isNullOrEmpty()) {
|
|
||||||
getString(R.string.settings_lock_none)
|
|
||||||
} else {
|
|
||||||
lockManager.locks?.joinToString(", ") {
|
|
||||||
when(it.type) {
|
|
||||||
Lock.Type.PATTERN -> getString(R.string.settings_lock_pattern)
|
|
||||||
Lock.Type.PIN -> getString(R.string.settings_lock_pin)
|
|
||||||
Lock.Type.PASSWORD -> getString(R.string.settings_lock_password)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
|
||||||
val intent = Intent(context, LockActivity::class.java)
|
|
||||||
activity?.startActivityForResult(intent, (activity as SettingsActivity).REQUEST_LOCK)
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class LockFragment : PreferenceFragmentCompat() {
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
|
|
||||||
val lockManager = LockManager(context!!)
|
|
||||||
|
|
||||||
findPreference<Preference>("lock_pattern")?.summary =
|
|
||||||
if (lockManager.contains(Lock.Type.PATTERN))
|
|
||||||
getString(R.string.settings_lock_enabled)
|
|
||||||
else
|
|
||||||
""
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
|
||||||
setPreferencesFromResource(R.xml.lock_preferences, rootKey)
|
|
||||||
|
|
||||||
with(findPreference<Preference>("lock_pattern")) {
|
|
||||||
this!!
|
|
||||||
|
|
||||||
if (LockManager(context!!).contains(Lock.Type.PATTERN))
|
|
||||||
summary = getString(R.string.settings_lock_enabled)
|
|
||||||
|
|
||||||
onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
|
||||||
val lockManager = LockManager(context!!)
|
|
||||||
|
|
||||||
if (lockManager.contains(Lock.Type.PATTERN)) {
|
|
||||||
AlertDialog.Builder(context).apply {
|
|
||||||
setTitle(R.string.warning)
|
|
||||||
setMessage(R.string.settings_lock_remove_message)
|
|
||||||
|
|
||||||
setPositiveButton(android.R.string.yes) { _, _ ->
|
|
||||||
lockManager.remove(Lock.Type.PATTERN)
|
|
||||||
onResume()
|
|
||||||
}
|
|
||||||
setNegativeButton(android.R.string.no) { _, _ -> }
|
|
||||||
}.show()
|
|
||||||
} else {
|
|
||||||
val intent = Intent(context, LockActivity::class.java).apply {
|
|
||||||
putExtra("mode", "add_lock")
|
|
||||||
putExtra("type", "pattern")
|
|
||||||
}
|
|
||||||
|
|
||||||
startActivity(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem?): Boolean {
|
|
||||||
when (item?.itemId) {
|
|
||||||
android.R.id.home -> onBackPressed()
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
|
||||||
when(requestCode) {
|
|
||||||
REQUEST_LOCK -> {
|
|
||||||
if (resultCode == Activity.RESULT_OK) {
|
|
||||||
supportFragmentManager
|
|
||||||
.beginTransaction()
|
|
||||||
.replace(R.id.settings, LockFragment())
|
|
||||||
.addToBackStack("Lock")
|
|
||||||
.commitAllowingStateLoss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> super.onActivityResult(requestCode, resultCode, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package xyz.quaver.pupil.ui.composable
|
||||||
|
|
||||||
|
enum class ContentType {
|
||||||
|
SINGLE_PANE, DUAL_PANE
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package xyz.quaver.pupil.ui.composable
|
||||||
|
|
||||||
|
import android.graphics.Rect
|
||||||
|
import androidx.window.layout.FoldingFeature
|
||||||
|
import kotlin.contracts.ExperimentalContracts
|
||||||
|
import kotlin.contracts.contract
|
||||||
|
|
||||||
|
sealed interface DevicePosture {
|
||||||
|
data object NormalPosture: DevicePosture
|
||||||
|
|
||||||
|
data class BookPosture(
|
||||||
|
val hingePosition: Rect
|
||||||
|
): DevicePosture
|
||||||
|
|
||||||
|
data class Separating(
|
||||||
|
val hingePosition: Rect,
|
||||||
|
val orientation: FoldingFeature.Orientation
|
||||||
|
): DevicePosture
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalContracts::class)
|
||||||
|
fun isBookPosture(foldingFeature: FoldingFeature?): Boolean {
|
||||||
|
contract { returns(true) implies (foldingFeature != null) }
|
||||||
|
|
||||||
|
return foldingFeature?.state == FoldingFeature.State.HALF_OPENED &&
|
||||||
|
foldingFeature.orientation == FoldingFeature.Orientation.VERTICAL
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalContracts::class)
|
||||||
|
fun isSeparating(foldingFeature: FoldingFeature?): Boolean {
|
||||||
|
contract { returns(true) implies (foldingFeature != null) }
|
||||||
|
|
||||||
|
return foldingFeature?.state == FoldingFeature.State.FLAT && foldingFeature.isSeparating
|
||||||
|
}
|
||||||
504
app/src/main/java/xyz/quaver/pupil/ui/composable/Gallery.kt
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
package xyz.quaver.pupil.ui.composable
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.InlineTextContent
|
||||||
|
import androidx.compose.foundation.text.appendInlineContent
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.BrokenImage
|
||||||
|
import androidx.compose.material.icons.filled.QuestionMark
|
||||||
|
import androidx.compose.material.icons.filled.StarOutline
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.Placeholder
|
||||||
|
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import coil.compose.SubcomposeAsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.networking.Artist
|
||||||
|
import xyz.quaver.pupil.networking.Character
|
||||||
|
import xyz.quaver.pupil.networking.GalleryFile
|
||||||
|
import xyz.quaver.pupil.networking.GalleryInfo
|
||||||
|
import xyz.quaver.pupil.networking.GalleryTag
|
||||||
|
import xyz.quaver.pupil.networking.Group
|
||||||
|
import xyz.quaver.pupil.networking.HitomiHttpClient
|
||||||
|
import xyz.quaver.pupil.networking.Language
|
||||||
|
import xyz.quaver.pupil.networking.SearchQuery
|
||||||
|
import xyz.quaver.pupil.networking.Series
|
||||||
|
import xyz.quaver.pupil.networking.joinToCapitalizedString
|
||||||
|
import xyz.quaver.pupil.ui.theme.Blue500
|
||||||
|
import xyz.quaver.pupil.ui.theme.Green500
|
||||||
|
import xyz.quaver.pupil.ui.theme.Purple500
|
||||||
|
import xyz.quaver.pupil.ui.theme.Red500
|
||||||
|
import xyz.quaver.pupil.ui.theme.Yellow500
|
||||||
|
|
||||||
|
private val languageMap = mapOf(
|
||||||
|
"indonesian" to "Bahasa Indonesia",
|
||||||
|
"catalan" to "català",
|
||||||
|
"cebuano" to "Cebuano",
|
||||||
|
"czech" to "Čeština",
|
||||||
|
"danish" to "Dansk",
|
||||||
|
"german" to "Deutsch",
|
||||||
|
"estonian" to "eesti",
|
||||||
|
"english" to "English",
|
||||||
|
"spanish" to "Español",
|
||||||
|
"esperanto" to "Esperanto",
|
||||||
|
"french" to "Français",
|
||||||
|
"italian" to "Italiano",
|
||||||
|
"latin" to "Latina",
|
||||||
|
"hungarian" to "magyar",
|
||||||
|
"dutch" to "Nederlands",
|
||||||
|
"norwegian" to "norsk",
|
||||||
|
"polish" to "polski",
|
||||||
|
"portuguese" to "Português",
|
||||||
|
"romanian" to "română",
|
||||||
|
"albanian" to "shqip",
|
||||||
|
"slovak" to "Slovenčina",
|
||||||
|
"finnish" to "Suomi",
|
||||||
|
"swedish" to "Svenska",
|
||||||
|
"tagalog" to "Tagalog",
|
||||||
|
"vietnamese" to "tiếng việt",
|
||||||
|
"turkish" to "Türkçe",
|
||||||
|
"greek" to "Ελληνικά",
|
||||||
|
"mongolian" to "Монгол",
|
||||||
|
"russian" to "Русский",
|
||||||
|
"ukrainian" to "Українська",
|
||||||
|
"hebrew" to "עברית",
|
||||||
|
"arabic" to "العربية",
|
||||||
|
"persian" to "فارسی",
|
||||||
|
"thai" to "ไทย",
|
||||||
|
"korean" to "한국어",
|
||||||
|
"chinese" to "中文",
|
||||||
|
"japanese" to "日本語"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val galleryTypeStringMap = mapOf(
|
||||||
|
"doujinshi" to R.string.doujinshi,
|
||||||
|
"manga" to R.string.manga,
|
||||||
|
"artistcg" to R.string.artist_cg,
|
||||||
|
"gamecg" to R.string.game_cg,
|
||||||
|
"imageset" to R.string.image_set
|
||||||
|
)
|
||||||
|
|
||||||
|
private val galleryTypeColorMap = mapOf(
|
||||||
|
"doujinshi" to Red500,
|
||||||
|
"manga" to Yellow500,
|
||||||
|
"artistcg" to Purple500,
|
||||||
|
"gamecg" to Green500,
|
||||||
|
"imageset" to Blue500
|
||||||
|
)
|
||||||
|
|
||||||
|
class GalleryInfoProvider: PreviewParameterProvider<GalleryInfo> {
|
||||||
|
override val values = sequenceOf(
|
||||||
|
GalleryInfo(
|
||||||
|
id = "2296437",
|
||||||
|
title = "Kakyuu Majutsushi, Inmon ni Somaru | 하급 마술사, 음문에 물들다",
|
||||||
|
language = "korean",
|
||||||
|
type = "doujinshi",
|
||||||
|
date = "2022-08-11 07:14:00-05",
|
||||||
|
artists = listOf(Artist("wagashi")),
|
||||||
|
groups = listOf(Group("dagashiya")),
|
||||||
|
series = listOf(Series("original")),
|
||||||
|
tags = listOf(
|
||||||
|
GalleryTag("ahegao", female="1"),
|
||||||
|
GalleryTag("big penis", male="1"),
|
||||||
|
GalleryTag("bike shorts", female="1"),
|
||||||
|
GalleryTag("blowjob", female="1"),
|
||||||
|
GalleryTag("blowjob face", female="1"),
|
||||||
|
GalleryTag("bukkake", female="1"),
|
||||||
|
GalleryTag("bunny girl", female="1"),
|
||||||
|
GalleryTag("clone", male="1"),
|
||||||
|
GalleryTag("corruption", female="1"),
|
||||||
|
GalleryTag("crotch tattoo", female="1"),
|
||||||
|
GalleryTag("gloves", female="1"),
|
||||||
|
GalleryTag("gokkun", female="1"),
|
||||||
|
GalleryTag("group"),
|
||||||
|
GalleryTag("kemonomimi", female="1"),
|
||||||
|
GalleryTag("leotard", female="1"),
|
||||||
|
GalleryTag("lingerie", female="1"),
|
||||||
|
GalleryTag("loli", female="1"),
|
||||||
|
GalleryTag("masked face", female="1"),
|
||||||
|
GalleryTag("masturbation", female="1"),
|
||||||
|
GalleryTag("mind control", female="1"),
|
||||||
|
GalleryTag("mmf threesome"),
|
||||||
|
GalleryTag("moral degeneration", female="1"),
|
||||||
|
GalleryTag("mouth mask", female="1"),
|
||||||
|
GalleryTag("nakadashi", female="1"),
|
||||||
|
GalleryTag("prostitution", female="1"),
|
||||||
|
GalleryTag("smell", male="1"),
|
||||||
|
GalleryTag("unusual pupils", female="1"),
|
||||||
|
),
|
||||||
|
related = listOf(2806924, 2806923, 2319091, 1647024, 2580808),
|
||||||
|
languages = listOf(
|
||||||
|
Language(galleryID="2806923", name="korean"),
|
||||||
|
Language(galleryID="2609305", name="english"),
|
||||||
|
Language(galleryID="2302333", name="spanish"),
|
||||||
|
Language(galleryID="2392785", name="portuguese"),
|
||||||
|
Language(galleryID="2303940", name="russian"),
|
||||||
|
Language(galleryID="2736129", name="chinese"),
|
||||||
|
Language(galleryID="2295647", name="japanese")
|
||||||
|
),
|
||||||
|
characters = listOf(Character("Kakyuu Majutsushi")),
|
||||||
|
files = listOf(
|
||||||
|
GalleryFile(name="01.jpg", hash="d441383396a6ba41a2db914328dc80d16b5191e53d23a5f0f9f8a0cd8f2e7cef", width=4185, height=6000, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="02.png", hash="a42517a19c7db6369749807bbc6676906e35709be07f780247f2e68d516ed1d5", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="03.png", hash="ee0841953755f34a0a146a7f757cf2993c678384f53e88715b1c97a00abe5c27", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="04.png", hash="66fafca77a7ed0287666e77fe268a02f75b4e27c2b9b77e6577bb3132396132b", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="05.png", hash="0ef8081ad9eef5093077c8551e87903e8b275e607634717857c2986e8d3c51a9", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="06.png", hash="2e59ffd59fa761355ea855a9e0f366cb39569207165a99659a9f0868cbba7e94", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="07.png", hash="5dc19fb97a2f1c64cae5634cd651f593d022b3114bbacaab15ba114be581cbea", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="08.png", hash="9121781d4f8fb1aaaee124f82c2ab98c97eb3e1f9508bab7c1d8771bb5eddfdb", width=4520, height=6295, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="09.png", hash="e5ceae4da5e497bd95a79a607f2c85bb3e8dc7386e041086cd5ccb9a9fb4dcb1", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="10.png", hash="be56219811a29f86dfcf5a7af0b25addc7436b134acd1a94d51c69c987da9d1b", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="11.png", hash="4e3b09ac015360ad4daffcea46265f3eb319bbe638ce90f26d4cbae37dc8c0f1", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="12.png", hash="5d57c0a0cd00604382eeaf0b32446b938dafc9d980eb086673e1fa0307166e39", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="13.png", hash="1ff2313fe979b52b826d482be90699a7c086afc251fa22306e92dfc582611f10", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="14.png", hash="7ad92a9408a059afafc68d3086c5f6a070c7e0d550bc2d328b5c9e16a62be01c", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="15.png", hash="4a6db95b7111b647e450c155af07c617d28313c02871ed94ad0c5986d3c5d1aa", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="16.png", hash="d4b53bf416c9bd2f72850e80ddaaf8467c663c72433c8ebebf3a70742fea7c32", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="17.png", hash="d189d5321f18414de816c049d3e2d72a7d31124d628037ca9f7da4572025cf01", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="18.png", hash="3ff372d7ba4e34cff9f7b46f5323e60b36f9b9df3dd5d02be4c71de4a7ee23c7", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="19.png", hash="2965852c2000fb17f756263b47ca196563995be2d03143f64c297f5930248d1e", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="20.png", hash="3713f95947cc6df0b67af5532a440f023c99ee37d483f3f9252400168e3a55ad", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="21.png", hash="c0b2b2d5ee79c3dc3b737c0eaff19ed1a731d81495adc2c94260de7ebbd85415", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="22.png", hash="8835fe309a26fce6882c0fcdf37cbd5f5bcc69dd2c32e436deb644891ca8499b", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="23.png", hash="5eb28619c1919ad29b86fb6cdaeccd50ad2ad857c81f56c060e8b66a2ed315d0", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="24.png", hash="7ea3ecf4c0b0e5e632163a5b0dc2475a071e1209a0fed8e8e49243093c35babb", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="25.png", hash="d01355724eb60c41e43159607652812d1fbbbac12962b2f9068a9e620ee0c246", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="26.png", hash="58a33c1d709b005a17600f7beb14a81711a106619bdb029d30646a9060c245c7", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="27.png", hash="0b18753f2fe7ea97c2e2c13a082a5a675f36085558bcd3fb7be916b6118c6000", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="28.png", hash="e8f0a2f9d35ec2c1974a4aea07eefd792462049e7b0972bf8cd5532dd82cee21", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="29.png", hash="90ec7a13aedf22c1f9317f75e843a4ace4e236d6100a8c8bb5aeb8160ff1cd00", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="30.png", hash="3229400975e6cef763c54a82a4ce0e6f51ec41c9b674072e74f66b41e325b655", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="31.png", hash="4e04efa56981804f7d13858a98935038fe421956367dcabba8e9118d273135d5", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="32.png", hash="e0d8641127aaaf587ec5e3040c49edee9cda866a5f1f377e567b908514b1eb68", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="33.png", hash="00125ab9090be7d3462fd6943c209bc68c236ef50ca0f1552530fd8253d87f7d", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="34.png", hash="1ea303198badaecfcf66a438baa6867690416d77a542f1ff0181b1895df95ccb", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="35.png", hash="40181ac057808a16716e640c462cc2b992822999ab8db43d454bb331d50bd39f", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="36.png", hash="92cbe1067c1c554c5a2e01b58b6fb86677a523cba586dfa209d61b4af70b2f54", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="37.png", hash="4fbbdc6d5450eb1f2f7e45a97ce8360d43e199454a8eb9e536740309c2067999", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="38.png", hash="0cb74b8af27e51604c728a26cf4788899fa04f854853badea9884b008c024e94", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="39.png", hash="254676c13ab418ce1110a9bad009554abc72af7e2ff9d719409881ea3c635d46", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="40.png", hash="33527e1171b9cf21ab164be2b76cb5b9daa91980a3330f2441dd9bf911e2e05b", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="41.png", hash="4dc331acb058665500aa308143c106efb6999855d1c3b0665c1dd331c8364430", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="42.png", hash="7674f03fa02cf96a20f1e192caf76d85e7e556add0cdb65aabdc86d417093d39", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="43.png", hash="27a4a280483142b213b26d53f06b991be35122cb263c69a87435e624b2a307fd", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="44.png", hash="3114ba3bff094abbae9160656894d462e0567cea23e6fa2693469c5e7defa6fe", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="45.png", hash="dc22ffed6a678a560e781f795b3ee0876ec8726ae2af942226710102758d44db", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="46.png", hash="4f7257ad75b990cc2b8dad0c5a09a831cb1670e7aaeadc71809f4bd7400f0071", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="47.png", hash="bf74fcd74ce77b5089bac98dc9f5737ba1fe87cdcfb01ad80e63024c93f82692", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="48.png", hash="d0baed210584183efa654ca7a483c558afe9eb18b71452ed2cfd4264b269ffca", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="49.png", hash="2b9c90a038e4e918655dffa84fbbb08cbedfce642277b1140d070aa9a711ebac", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="50.png", hash="cdfba6a9b8a570f2f317fb01e66ebc72ede5735a41256b326a38af2296799095", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="51.png", hash="84ad9d51aab2e8ae1cbb5a0918cc3f62473e98512db9f473ab1ffe3a4f7aa75a", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="52.png", hash="0d7a420aa23e23cee7f8e124b12080e309cffb3d7269389f23e1ac3d5b7363a5", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="53.png", hash="575cf99346f9786d6e30bc163dc6d5fa439bd157a9ffce90586bfc5657121981", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="54.png", hash="9004d3fd46dc278b79cf7a5bf72002dd4b0b03ad6bded30ceebcb633185b49ce", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="55.png", hash="8e53c301e2c2b539783c7b8c4f5028f221198d33e114dcc40e6a0025aee840ab", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="56.png", hash="576764073554fcf644c12d80f26b3bae42f39f3516ed7841c9e297248324b237", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="57.png", hash="110da01b9ae7ed4a145792f55498a5efa22cebec4da84f6220904840b508c75e", width=4535, height=6307, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
GalleryFile(name="58.jpg", hash="3ae38577135465b6224e0487c0cdcd37cf11764883f3b78a67545d48c6beade5", width=4260, height=6000, hasWebP=1, hasAVIF=1, hasJXL=0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun TagGroup(tags: List<SearchQuery.Tag>, folded: Boolean = false) {
|
||||||
|
var isFolded by remember { mutableStateOf(folded) }
|
||||||
|
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
tags.sortedBy {
|
||||||
|
when(it.namespace) {
|
||||||
|
"female" -> 1
|
||||||
|
"male" -> 2
|
||||||
|
else -> 3
|
||||||
|
}
|
||||||
|
}.let {
|
||||||
|
if (isFolded) it.take(10) else it
|
||||||
|
}.forEach { tag ->
|
||||||
|
TagChip(tag = tag.toTag())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFolded && tags.size > 10)
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.height(32.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
onClick = { isFolded = false }
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"…",
|
||||||
|
modifier = Modifier.padding(16.dp, 8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GalleryTypeIndicator(galleryType: String) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.height(32.dp),
|
||||||
|
color = galleryTypeColorMap[galleryType] ?: MaterialTheme.colorScheme.surface,
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxHeight()) {
|
||||||
|
Text(
|
||||||
|
galleryTypeStringMap[galleryType]?.let { stringResource(it) } ?: galleryType,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.align(Alignment.Center),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LanguageTitle(title: String, language: String?) {
|
||||||
|
val icon = languageIconMap[language]
|
||||||
|
|
||||||
|
if (icon != null) {
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
appendInlineContent("language", "<language>")
|
||||||
|
append(' ')
|
||||||
|
append(title)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
inlineContent = mapOf(
|
||||||
|
"language" to InlineTextContent(
|
||||||
|
Placeholder(
|
||||||
|
width = 20.sp,
|
||||||
|
height = 20.sp,
|
||||||
|
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painterResource(icon),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.Unspecified
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(title, style = MaterialTheme.typography.headlineSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DetailedGalleryInfoHeader(galleryInfo: GalleryInfo, thumbnailUrl: String?) {
|
||||||
|
val thumbnailFile = galleryInfo.files.first()
|
||||||
|
val aspectRatio = thumbnailFile.let { it.width / it.height.toFloat() }
|
||||||
|
|
||||||
|
if (thumbnailFile.let { it.width > it.height }) {
|
||||||
|
Column {
|
||||||
|
if (thumbnailUrl != null) {
|
||||||
|
SubcomposeAsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data(thumbnailUrl)
|
||||||
|
.setHeader("Referer", "https://hitomi.la/")
|
||||||
|
.build(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(aspectRatio)
|
||||||
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
|
loading = { CircularProgressIndicator(Modifier.align(Alignment.Center)) },
|
||||||
|
error = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.BrokenImage,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
contentDescription = "Thumbnail"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(aspectRatio)) {
|
||||||
|
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
LanguageTitle(galleryInfo.title, galleryInfo.language)
|
||||||
|
|
||||||
|
val artistsAndGroups = buildString {
|
||||||
|
if (!galleryInfo.artists.isNullOrEmpty())
|
||||||
|
append(galleryInfo.artists.joinToCapitalizedString())
|
||||||
|
|
||||||
|
if (!galleryInfo.groups.isNullOrEmpty()) {
|
||||||
|
if (this.isNotEmpty()) append(' ')
|
||||||
|
append('(')
|
||||||
|
append(galleryInfo.groups.joinToCapitalizedString())
|
||||||
|
append(')')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artistsAndGroups.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
artistsAndGroups,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (thumbnailUrl != null) {
|
||||||
|
SubcomposeAsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data(thumbnailUrl)
|
||||||
|
.setHeader("Referer", "https://hitomi.la/")
|
||||||
|
.build(),
|
||||||
|
modifier = Modifier
|
||||||
|
.height(200.dp)
|
||||||
|
.aspectRatio(aspectRatio)
|
||||||
|
.clip(RoundedCornerShape(8.dp)),
|
||||||
|
loading = { CircularProgressIndicator(Modifier.align(Alignment.Center)) },
|
||||||
|
error = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.BrokenImage,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
contentDescription = "Thumbnail"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.height(200.dp)
|
||||||
|
.aspectRatio(aspectRatio)) {
|
||||||
|
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(Modifier.heightIn(min = 200.dp)) {
|
||||||
|
LanguageTitle(galleryInfo.title, galleryInfo.language)
|
||||||
|
|
||||||
|
val artistsAndGroups = buildString {
|
||||||
|
if (!galleryInfo.artists.isNullOrEmpty())
|
||||||
|
append(galleryInfo.artists.joinToCapitalizedString())
|
||||||
|
|
||||||
|
if (!galleryInfo.groups.isNullOrEmpty()) {
|
||||||
|
if (this.isNotEmpty()) append(' ')
|
||||||
|
append('(')
|
||||||
|
append(galleryInfo.groups.joinToCapitalizedString())
|
||||||
|
append(')')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artistsAndGroups.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
artistsAndGroups,
|
||||||
|
style = MaterialTheme.typography.labelLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun DetailedGalleryInfo(
|
||||||
|
@PreviewParameter(GalleryInfoProvider::class) galleryInfo: GalleryInfo,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var thumbnailUrl by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(galleryInfo) {
|
||||||
|
thumbnailUrl = galleryInfo.files.firstOrNull()?.let {
|
||||||
|
HitomiHttpClient.getImageURL(it, true).firstOrNull()
|
||||||
|
} ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(modifier) {
|
||||||
|
Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
DetailedGalleryInfoHeader(galleryInfo, thumbnailUrl)
|
||||||
|
|
||||||
|
GalleryTypeIndicator(galleryInfo.type)
|
||||||
|
|
||||||
|
if (galleryInfo.tags?.isNotEmpty() == true) {
|
||||||
|
TagGroup(galleryInfo.tags.map { it.toTag() }, folded = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(4.dp)) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.align(Alignment.CenterStart),
|
||||||
|
text = galleryInfo.id,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
text = "${galleryInfo.files.size}P",
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterEnd)
|
||||||
|
.size(32.dp),
|
||||||
|
imageVector = Icons.Default.StarOutline,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Yellow500
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
306
app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
package xyz.quaver.pupil.ui.composable
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.ime
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
|
import androidx.compose.material3.PermanentNavigationDrawer
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
import androidx.compose.material3.windowsizeclass.WindowHeightSizeClass
|
||||||
|
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||||
|
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.activity
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.window.layout.DisplayFeature
|
||||||
|
import androidx.window.layout.FoldingFeature
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.networking.GalleryInfo
|
||||||
|
import xyz.quaver.pupil.networking.SearchQuery
|
||||||
|
import xyz.quaver.pupil.ui.viewmodel.SearchState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainApp(
|
||||||
|
windowSize: WindowSizeClass,
|
||||||
|
displayFeatures: List<DisplayFeature>,
|
||||||
|
uiState: SearchState,
|
||||||
|
navController: NavHostController,
|
||||||
|
openGalleryDetails: (GalleryInfo) -> Unit,
|
||||||
|
closeGalleryDetails: () -> Unit,
|
||||||
|
onQueryChange: (SearchQuery?) -> Unit,
|
||||||
|
loadSearchResult: (IntRange) -> Unit,
|
||||||
|
) {
|
||||||
|
val navigationType: NavigationType
|
||||||
|
val contentType: ContentType
|
||||||
|
|
||||||
|
val foldingFeature: FoldingFeature? = displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()
|
||||||
|
val foldingDevicePosture = when {
|
||||||
|
isBookPosture(foldingFeature) -> DevicePosture.BookPosture(foldingFeature.bounds)
|
||||||
|
isSeparating(foldingFeature) -> DevicePosture.Separating(foldingFeature.bounds, foldingFeature.orientation)
|
||||||
|
else -> DevicePosture.NormalPosture
|
||||||
|
}
|
||||||
|
|
||||||
|
when (windowSize.widthSizeClass) {
|
||||||
|
WindowWidthSizeClass.Compact -> {
|
||||||
|
navigationType = NavigationType.BOTTOM_NAVIGATION
|
||||||
|
contentType = ContentType.SINGLE_PANE
|
||||||
|
}
|
||||||
|
WindowWidthSizeClass.Medium -> {
|
||||||
|
navigationType = NavigationType.NAVIGATION_RAIL
|
||||||
|
contentType = if (foldingDevicePosture != DevicePosture.NormalPosture) {
|
||||||
|
ContentType.DUAL_PANE
|
||||||
|
} else {
|
||||||
|
ContentType.SINGLE_PANE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WindowWidthSizeClass.Expanded -> {
|
||||||
|
navigationType = if (foldingDevicePosture is DevicePosture.BookPosture) {
|
||||||
|
NavigationType.NAVIGATION_RAIL
|
||||||
|
} else {
|
||||||
|
NavigationType.PERMANENT_NAVIGATION_DRAWER
|
||||||
|
}
|
||||||
|
contentType = ContentType.DUAL_PANE
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
navigationType = NavigationType.BOTTOM_NAVIGATION
|
||||||
|
contentType = ContentType.SINGLE_PANE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val navigationContentPosition = when (windowSize.heightSizeClass) {
|
||||||
|
WindowHeightSizeClass.Compact -> NavigationContentPosition.TOP
|
||||||
|
WindowHeightSizeClass.Medium,
|
||||||
|
WindowHeightSizeClass.Expanded -> NavigationContentPosition.CENTER
|
||||||
|
else -> NavigationContentPosition.TOP
|
||||||
|
}
|
||||||
|
|
||||||
|
MainNavigationWrapper(
|
||||||
|
navigationType,
|
||||||
|
contentType,
|
||||||
|
displayFeatures,
|
||||||
|
navigationContentPosition,
|
||||||
|
uiState,
|
||||||
|
navController,
|
||||||
|
openGalleryDetails = openGalleryDetails,
|
||||||
|
closeGalleryDetails = closeGalleryDetails,
|
||||||
|
onQueryChange = onQueryChange,
|
||||||
|
loadSearchResult = loadSearchResult
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MainNavigationWrapper(
|
||||||
|
navigationType: NavigationType,
|
||||||
|
contentType: ContentType,
|
||||||
|
displayFeatures: List<DisplayFeature>,
|
||||||
|
navigationContentPosition: NavigationContentPosition,
|
||||||
|
uiState: SearchState,
|
||||||
|
navController: NavHostController,
|
||||||
|
openGalleryDetails: (GalleryInfo) -> Unit,
|
||||||
|
closeGalleryDetails: () -> Unit,
|
||||||
|
onQueryChange: (SearchQuery?) -> Unit,
|
||||||
|
loadSearchResult: (IntRange) -> Unit
|
||||||
|
) {
|
||||||
|
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
|
|
||||||
|
val openDrawer: () -> Unit = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
drawerState.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigationType == NavigationType.PERMANENT_NAVIGATION_DRAWER) {
|
||||||
|
PermanentNavigationDrawer(
|
||||||
|
drawerContent = {
|
||||||
|
PermanentNavigationDrawerContent(
|
||||||
|
selectedDestination = currentRoute,
|
||||||
|
navigateToDestination = { navController.navigate(it.route) {
|
||||||
|
popUpTo(MainDestination.Search.route)
|
||||||
|
launchSingleTop = true
|
||||||
|
} },
|
||||||
|
navigationContentPosition = navigationContentPosition,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
MainContent(
|
||||||
|
navigationType = navigationType,
|
||||||
|
contentType = contentType,
|
||||||
|
displayFeatures = displayFeatures,
|
||||||
|
uiState = uiState,
|
||||||
|
navController = navController,
|
||||||
|
onDrawerClicked = openDrawer,
|
||||||
|
openGalleryDetails = openGalleryDetails,
|
||||||
|
closeGalleryDetails = closeGalleryDetails,
|
||||||
|
onQueryChange = onQueryChange,
|
||||||
|
loadSearchResult = loadSearchResult,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerContent = {
|
||||||
|
ModalNavigationDrawerContent(
|
||||||
|
selectedDestination = currentRoute,
|
||||||
|
navigateToDestination = { navController.navigate(it.route) {
|
||||||
|
popUpTo(MainDestination.Search.route)
|
||||||
|
launchSingleTop = true
|
||||||
|
} },
|
||||||
|
navigationContentPosition = navigationContentPosition,
|
||||||
|
onDrawerClicked = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
drawerState.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
drawerState = drawerState
|
||||||
|
) {
|
||||||
|
MainContent(
|
||||||
|
navigationType = navigationType,
|
||||||
|
contentType = contentType,
|
||||||
|
displayFeatures = displayFeatures,
|
||||||
|
uiState = uiState,
|
||||||
|
navController = navController,
|
||||||
|
onDrawerClicked = openDrawer,
|
||||||
|
openGalleryDetails = openGalleryDetails,
|
||||||
|
closeGalleryDetails = closeGalleryDetails,
|
||||||
|
onQueryChange = onQueryChange,
|
||||||
|
loadSearchResult = loadSearchResult,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NotImplemented() {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Column(
|
||||||
|
Modifier.align(Alignment.Center),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text("(⁄ ⁄•⁄ω⁄•⁄ ⁄)", style = MaterialTheme.typography.headlineMedium, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f))
|
||||||
|
Text(stringResource(R.string.not_implemented), textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainContent(
|
||||||
|
navigationType: NavigationType,
|
||||||
|
contentType: ContentType,
|
||||||
|
displayFeatures: List<DisplayFeature>,
|
||||||
|
uiState: SearchState,
|
||||||
|
navController: NavHostController,
|
||||||
|
onDrawerClicked: () -> Unit,
|
||||||
|
openGalleryDetails: (GalleryInfo) -> Unit,
|
||||||
|
closeGalleryDetails: () -> Unit,
|
||||||
|
onQueryChange: (SearchQuery?) -> Unit,
|
||||||
|
loadSearchResult: (IntRange) -> Unit,
|
||||||
|
) {
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentRoute = navBackStackEntry?.destination?.route
|
||||||
|
|
||||||
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
|
AnimatedVisibility(visible = navigationType == NavigationType.NAVIGATION_RAIL) {
|
||||||
|
MainNavigationRail(
|
||||||
|
selectedDestination = currentRoute,
|
||||||
|
navigateToDestination = { navController.navigate(it.route) {
|
||||||
|
popUpTo(MainDestination.Search.route)
|
||||||
|
launchSingleTop = true
|
||||||
|
} },
|
||||||
|
onDrawerClicked = onDrawerClicked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.inverseOnSurface)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.run {
|
||||||
|
if (navigationType == NavigationType.BOTTOM_NAVIGATION) {
|
||||||
|
this
|
||||||
|
.consumeWindowInsets(WindowInsets.ime)
|
||||||
|
.consumeWindowInsets(WindowInsets.navigationBars)
|
||||||
|
} else this
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
NavHost(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
navController = navController,
|
||||||
|
startDestination = MainDestination.Search.route
|
||||||
|
) {
|
||||||
|
composable(MainDestination.Search.route) {
|
||||||
|
SearchScreen(
|
||||||
|
contentType = contentType,
|
||||||
|
displayFeatures = displayFeatures,
|
||||||
|
uiState = uiState,
|
||||||
|
openGalleryDetails = openGalleryDetails,
|
||||||
|
closeGalleryDetails = closeGalleryDetails,
|
||||||
|
onQueryChange = onQueryChange,
|
||||||
|
loadSearchResult = loadSearchResult,
|
||||||
|
openGallery = {
|
||||||
|
navController.navigate(MainDestination.ImageViewer(it.id).route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(MainDestination.History.route) {
|
||||||
|
NotImplemented()
|
||||||
|
}
|
||||||
|
composable(MainDestination.Downloads.route) {
|
||||||
|
NotImplemented()
|
||||||
|
}
|
||||||
|
composable(MainDestination.Favorites.route) {
|
||||||
|
NotImplemented()
|
||||||
|
}
|
||||||
|
composable(MainDestination.Settings.route) {
|
||||||
|
NotImplemented()
|
||||||
|
}
|
||||||
|
composable(MainDestination.ImageViewer.commonRoute) {
|
||||||
|
NotImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AnimatedVisibility(visible = navigationType == NavigationType.BOTTOM_NAVIGATION) {
|
||||||
|
BottomNavigationBar(
|
||||||
|
selectedDestination = currentRoute,
|
||||||
|
navigateToDestination = { navController.navigate(it.route) {
|
||||||
|
popUpTo(MainDestination.Search.route)
|
||||||
|
launchSingleTop = true
|
||||||
|
} }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package xyz.quaver.pupil.ui.composable
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.MenuBook
|
||||||
|
import androidx.compose.material.icons.filled.Download
|
||||||
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
|
import androidx.compose.material.icons.filled.History
|
||||||
|
import androidx.compose.material.icons.filled.MenuBook
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Star
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
|
||||||
|
sealed interface MainDestination {
|
||||||
|
val route: String
|
||||||
|
val icon: ImageVector
|
||||||
|
val textId: Int
|
||||||
|
|
||||||
|
data object Search: MainDestination {
|
||||||
|
override val route = "search"
|
||||||
|
override val icon = Icons.Default.Search
|
||||||
|
override val textId = R.string.main_destination_search
|
||||||
|
}
|
||||||
|
|
||||||
|
data object History: MainDestination {
|
||||||
|
override val route = "history"
|
||||||
|
override val icon = Icons.Default.History
|
||||||
|
override val textId = R.string.main_destination_history
|
||||||
|
}
|
||||||
|
|
||||||
|
data object Downloads: MainDestination {
|
||||||
|
override val route = "downloads"
|
||||||
|
override val icon = Icons.Default.Download
|
||||||
|
override val textId = R.string.main_destination_downloads
|
||||||
|
}
|
||||||
|
|
||||||
|
data object Favorites: MainDestination {
|
||||||
|
override val route = "favorites"
|
||||||
|
override val icon = Icons.Default.Favorite
|
||||||
|
override val textId = R.string.main_destination_favorites
|
||||||
|
}
|
||||||
|
|
||||||
|
data object Settings: MainDestination {
|
||||||
|
override val route = "settings"
|
||||||
|
override val icon = Icons.Default.Settings
|
||||||
|
override val textId = R.string.main_destination_settings
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImageViewer(galleryID: String): MainDestination {
|
||||||
|
override val route = "image_viewer/$galleryID"
|
||||||
|
override val icon = Icons.AutoMirrored.Filled.MenuBook
|
||||||
|
override val textId = R.string.main_destination_image_viewer
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val commonRoute = "image_viewer/{galleryID}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val mainDestinations = listOf(
|
||||||
|
MainDestination.Search,
|
||||||
|
MainDestination.History,
|
||||||
|
MainDestination.Downloads,
|
||||||
|
MainDestination.Favorites,
|
||||||
|
MainDestination.Settings
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package xyz.quaver.pupil.ui.composable
|
||||||
|
|
||||||
|
enum class NavigationContentPosition {
|
||||||
|
TOP, CENTER
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package xyz.quaver.pupil.ui.composable
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.ime
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.sizeIn
|
||||||
|
import androidx.compose.foundation.layout.union
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.MenuOpen
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
|
import androidx.compose.material3.NavigationBar
|
||||||
|
import androidx.compose.material3.NavigationBarItem
|
||||||
|
import androidx.compose.material3.NavigationDrawerItem
|
||||||
|
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||||
|
import androidx.compose.material3.NavigationRail
|
||||||
|
import androidx.compose.material3.NavigationRailItem
|
||||||
|
import androidx.compose.material3.PermanentDrawerSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
|
import androidx.compose.ui.layout.Measurable
|
||||||
|
import androidx.compose.ui.layout.MeasurePolicy
|
||||||
|
import androidx.compose.ui.layout.layoutId
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.offset
|
||||||
|
import androidx.navigation.NavDestination
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PermanentNavigationDrawerContent(
|
||||||
|
selectedDestination: String?,
|
||||||
|
navigateToDestination: (MainDestination) -> Unit,
|
||||||
|
navigationContentPosition: NavigationContentPosition,
|
||||||
|
) {
|
||||||
|
PermanentDrawerSheet(
|
||||||
|
modifier = Modifier.sizeIn(minWidth = 200.dp, maxWidth = 300.dp),
|
||||||
|
drawerContainerColor = MaterialTheme.colorScheme.inverseOnSurface
|
||||||
|
) {
|
||||||
|
Layout(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(MaterialTheme.colorScheme.inverseOnSurface)
|
||||||
|
.padding(16.dp),
|
||||||
|
content = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.layoutId(LayoutType.HEADER),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
painter = painterResource(R.drawable.app_icon),
|
||||||
|
tint = Color.Unspecified,
|
||||||
|
contentDescription = "app icon"
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
text = "Pupil",
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.layoutId(LayoutType.CONTENT)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
mainDestinations.forEach { destination ->
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(destination.textId),
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = destination.icon,
|
||||||
|
contentDescription = stringResource(destination.textId)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
selected = selectedDestination == destination.route,
|
||||||
|
colors = NavigationDrawerItemDefaults.colors(
|
||||||
|
unselectedContainerColor = Color.Transparent
|
||||||
|
),
|
||||||
|
onClick = { navigateToDestination(destination) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
measurePolicy = navigationMeasurePolicy(navigationContentPosition)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ModalNavigationDrawerContent(
|
||||||
|
selectedDestination: String?,
|
||||||
|
navigationContentPosition: NavigationContentPosition,
|
||||||
|
navigateToDestination: (MainDestination) -> Unit,
|
||||||
|
onDrawerClicked: () -> Unit
|
||||||
|
) {
|
||||||
|
ModalDrawerSheet {
|
||||||
|
Layout(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(MaterialTheme.colorScheme.inverseOnSurface)
|
||||||
|
.padding(16.dp),
|
||||||
|
content = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.layoutId(LayoutType.HEADER)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
painter = painterResource(R.drawable.app_icon),
|
||||||
|
tint = Color.Unspecified,
|
||||||
|
contentDescription = "app icon"
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
text = "Pupil",
|
||||||
|
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = onDrawerClicked) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Default.MenuOpen,
|
||||||
|
contentDescription = stringResource(R.string.main_open_navigation_drawer)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column (
|
||||||
|
modifier = Modifier
|
||||||
|
.layoutId(LayoutType.CONTENT)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
mainDestinations.forEach { destination ->
|
||||||
|
NavigationDrawerItem(
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(destination.textId),
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = destination.icon,
|
||||||
|
contentDescription = stringResource(destination.textId)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
selected = selectedDestination == destination.route,
|
||||||
|
colors = NavigationDrawerItemDefaults.colors(
|
||||||
|
unselectedContainerColor = Color.Transparent
|
||||||
|
),
|
||||||
|
onClick = { navigateToDestination(destination) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
measurePolicy = navigationMeasurePolicy(navigationContentPosition)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MainNavigationRail(
|
||||||
|
selectedDestination: String?,
|
||||||
|
navigateToDestination: (MainDestination) -> Unit,
|
||||||
|
onDrawerClicked: () -> Unit
|
||||||
|
) {
|
||||||
|
NavigationRail (
|
||||||
|
modifier = Modifier.fillMaxHeight(),
|
||||||
|
containerColor = MaterialTheme.colorScheme.inverseOnSurface
|
||||||
|
) {
|
||||||
|
NavigationRailItem(
|
||||||
|
selected = false,
|
||||||
|
onClick = onDrawerClicked,
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Menu,
|
||||||
|
contentDescription = stringResource(R.string.main_open_navigation_drawer)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
mainDestinations.forEach { destination ->
|
||||||
|
NavigationRailItem(
|
||||||
|
selected = selectedDestination == destination.route,
|
||||||
|
onClick = { navigateToDestination(destination) },
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = destination.icon,
|
||||||
|
contentDescription = stringResource(destination.textId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BottomNavigationBar(
|
||||||
|
selectedDestination: String?,
|
||||||
|
navigateToDestination: (MainDestination) -> Unit
|
||||||
|
) {
|
||||||
|
NavigationBar(modifier = Modifier.fillMaxWidth(), windowInsets = WindowInsets.ime.union(WindowInsets.navigationBars)) {
|
||||||
|
mainDestinations.forEach { destination ->
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = selectedDestination == destination.route,
|
||||||
|
onClick = { navigateToDestination(destination) },
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = destination.icon,
|
||||||
|
contentDescription = stringResource(destination.textId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigationMeasurePolicy(
|
||||||
|
navigationContentPosition: NavigationContentPosition,
|
||||||
|
): MeasurePolicy {
|
||||||
|
return MeasurePolicy { measurables, constraints ->
|
||||||
|
lateinit var headerMeasurable: Measurable
|
||||||
|
lateinit var contentMeasurable: Measurable
|
||||||
|
measurables.forEach {
|
||||||
|
when (it.layoutId) {
|
||||||
|
LayoutType.HEADER -> headerMeasurable = it
|
||||||
|
LayoutType.CONTENT -> contentMeasurable = it
|
||||||
|
else -> error("Unknown layoutId encountered!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val headerPlaceable = headerMeasurable.measure(constraints)
|
||||||
|
val contentPlaceable = contentMeasurable.measure(
|
||||||
|
constraints.offset(vertical = -headerPlaceable.height)
|
||||||
|
)
|
||||||
|
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||||
|
headerPlaceable.placeRelative(0, 0)
|
||||||
|
|
||||||
|
val nonContentVerticalSpace = constraints.maxHeight - contentPlaceable.height
|
||||||
|
|
||||||
|
val contentPlaceableY = when (navigationContentPosition) {
|
||||||
|
NavigationContentPosition.TOP -> 0
|
||||||
|
NavigationContentPosition.CENTER -> nonContentVerticalSpace / 2
|
||||||
|
}.coerceAtLeast(headerPlaceable.height)
|
||||||
|
|
||||||
|
contentPlaceable.placeRelative(0, contentPlaceableY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class LayoutType {
|
||||||
|
HEADER, CONTENT
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package xyz.quaver.pupil.ui.composable
|
||||||
|
|
||||||
|
enum class NavigationType {
|
||||||
|
NAVIGATION_RAIL, PERMANENT_NAVIGATION_DRAWER, BOTTOM_NAVIGATION
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package xyz.quaver.pupil.ui.composable
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.NavigateBefore
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.NavigateNext
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
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.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
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.changedToUpIgnoreConsumed
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.input.pointer.positionChange
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.toSize
|
||||||
|
import androidx.compose.ui.util.fastFirstOrNull
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.ui.theme.Blue300
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OverscrollPager(
|
||||||
|
prevPage: Int?,
|
||||||
|
nextPage: Int?,
|
||||||
|
onPageTurn: (Int) -> Unit,
|
||||||
|
prevPageTurnIndicatorOffset: Dp = 0.dp,
|
||||||
|
nextPageTurnIndicatorOffset: Dp = 0.dp,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val haptic = LocalHapticFeedback.current
|
||||||
|
val pageTurnIndicatorHeight = LocalDensity.current.run { 64.dp.toPx() }
|
||||||
|
|
||||||
|
var overscroll: Float? by remember { mutableStateOf(null) }
|
||||||
|
|
||||||
|
var size: Size? by remember { mutableStateOf(null) }
|
||||||
|
val circleRadius = (size?.width ?: 0f) / 2
|
||||||
|
|
||||||
|
val topCircleRadius by animateFloatAsState(if (overscroll?.let { it >= pageTurnIndicatorHeight } == true) circleRadius else 0f, label = "topCircleRadius")
|
||||||
|
val bottomCircleRadius by animateFloatAsState(if (overscroll?.let { it <= -pageTurnIndicatorHeight } == true) circleRadius else 0f, label = "bottomCircleRadius")
|
||||||
|
|
||||||
|
val prevPageTurnIndicatorOffsetPx = LocalDensity.current.run { prevPageTurnIndicatorOffset.toPx() }
|
||||||
|
val nextPageTurnIndicatorOffsetPx = LocalDensity.current.run { nextPageTurnIndicatorOffset.toPx() }
|
||||||
|
|
||||||
|
if (topCircleRadius != 0f || bottomCircleRadius != 0f)
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
drawCircle(
|
||||||
|
Blue300,
|
||||||
|
center = Offset(this.center.x, prevPageTurnIndicatorOffsetPx),
|
||||||
|
radius = topCircleRadius
|
||||||
|
)
|
||||||
|
drawCircle(
|
||||||
|
Blue300,
|
||||||
|
center = Offset(this.center.x, this.size.height-nextPageTurnIndicatorOffsetPx),
|
||||||
|
radius = bottomCircleRadius
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val isOverscrollOverHeight = overscroll?.let { abs(it) >= pageTurnIndicatorHeight } == true
|
||||||
|
LaunchedEffect(isOverscrollOverHeight) {
|
||||||
|
if (isOverscrollOverHeight) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.onGloballyPositioned {
|
||||||
|
size = it.size.toSize()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
overscroll?.let { overscroll ->
|
||||||
|
if (overscroll > 0f && prevPage != null) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.offset(0.dp, prevPageTurnIndicatorOffset),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.NavigateBefore,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.secondary,
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
)
|
||||||
|
Text(stringResource(R.string.move_to_page, prevPage))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overscroll < 0f && nextPage != null) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.offset(0.dp, -nextPageTurnIndicatorOffset),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.move_to_page, nextPage))
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.NavigateNext,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.secondary,
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.offset(
|
||||||
|
0.dp,
|
||||||
|
overscroll
|
||||||
|
?.coerceIn(-pageTurnIndicatorHeight, pageTurnIndicatorHeight)
|
||||||
|
?.let { overscroll -> LocalDensity.current.run { overscroll.toDp() } }
|
||||||
|
?: 0.dp)
|
||||||
|
.nestedScroll(object : NestedScrollConnection {
|
||||||
|
override fun onPreScroll(
|
||||||
|
available: Offset,
|
||||||
|
source: NestedScrollSource
|
||||||
|
): Offset {
|
||||||
|
val overscrollSnapshot = overscroll
|
||||||
|
|
||||||
|
return if (overscrollSnapshot == null || overscrollSnapshot == 0f) {
|
||||||
|
Offset.Zero
|
||||||
|
} else {
|
||||||
|
val newOverscroll =
|
||||||
|
if (overscrollSnapshot > 0f && available.y < 0f)
|
||||||
|
max(overscrollSnapshot + available.y, 0f)
|
||||||
|
else if (overscrollSnapshot < 0f && available.y > 0f)
|
||||||
|
min(overscrollSnapshot + available.y, 0f)
|
||||||
|
else
|
||||||
|
overscrollSnapshot
|
||||||
|
|
||||||
|
Offset(0f, newOverscroll - overscrollSnapshot).also {
|
||||||
|
overscroll = newOverscroll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPostScroll(
|
||||||
|
consumed: Offset,
|
||||||
|
available: Offset,
|
||||||
|
source: NestedScrollSource
|
||||||
|
): Offset {
|
||||||
|
if (
|
||||||
|
available.y == 0f ||
|
||||||
|
prevPage == null && available.y > 0f ||
|
||||||
|
nextPage == null && available.y < 0f
|
||||||
|
) return Offset.Zero
|
||||||
|
|
||||||
|
return overscroll?.let {
|
||||||
|
overscroll = it + available.y
|
||||||
|
Offset(0f, available.y)
|
||||||
|
} ?: Offset.Zero
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.pointerInput(prevPage, nextPage) {
|
||||||
|
awaitEachGesture {
|
||||||
|
val down = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
var pointer = down.id
|
||||||
|
overscroll = 0f
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val event = awaitPointerEvent()
|
||||||
|
val dragEvent =
|
||||||
|
event.changes.fastFirstOrNull { it.id == pointer }!!
|
||||||
|
|
||||||
|
if (dragEvent.changedToUpIgnoreConsumed()) {
|
||||||
|
val otherDown = event.changes.fastFirstOrNull { it.pressed }
|
||||||
|
if (otherDown == null) {
|
||||||
|
if (dragEvent.positionChange() != Offset.Zero) dragEvent.consume()
|
||||||
|
overscroll?.let {
|
||||||
|
if (abs(it) > pageTurnIndicatorHeight) {
|
||||||
|
if (it > 0 && prevPage != null) onPageTurn(prevPage)
|
||||||
|
if (it < 0 && nextPage != null) onPageTurn(nextPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overscroll = null
|
||||||
|
break
|
||||||
|
} else
|
||||||
|
pointer = otherDown.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
793
app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt
Normal file
@@ -0,0 +1,793 @@
|
|||||||
|
package xyz.quaver.pupil.ui.composable
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.animateScrollBy
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.AddCircleOutline
|
||||||
|
import androidx.compose.material.icons.filled.RemoveCircleOutline
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardColors
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.toMutableStateList
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
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.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onKeyEvent
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextRange
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.networking.HitomiHttpClient
|
||||||
|
import xyz.quaver.pupil.networking.SearchQuery
|
||||||
|
import xyz.quaver.pupil.networking.Suggestion
|
||||||
|
import xyz.quaver.pupil.networking.validNamespace
|
||||||
|
import xyz.quaver.pupil.ui.theme.Blue300
|
||||||
|
import xyz.quaver.pupil.ui.theme.Blue600
|
||||||
|
import xyz.quaver.pupil.ui.theme.Gray300
|
||||||
|
import xyz.quaver.pupil.ui.theme.Pink600
|
||||||
|
import xyz.quaver.pupil.ui.theme.Red300
|
||||||
|
import xyz.quaver.pupil.ui.theme.Yellow400
|
||||||
|
|
||||||
|
private fun SearchQuery.toEditableStateInternal(): EditableSearchQueryState = when (this) {
|
||||||
|
is SearchQuery.Tag -> EditableSearchQueryState.Tag(namespace, tag)
|
||||||
|
is SearchQuery.And -> EditableSearchQueryState.And(queries.map { it.toEditableStateInternal() })
|
||||||
|
is SearchQuery.Or -> EditableSearchQueryState.Or(queries.map { it.toEditableStateInternal() })
|
||||||
|
is SearchQuery.Not -> EditableSearchQueryState.Not(query.toEditableStateInternal())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SearchQuery?.toEditableState(): EditableSearchQueryState.Root =
|
||||||
|
EditableSearchQueryState.Root(this?.toEditableStateInternal())
|
||||||
|
|
||||||
|
private fun EditableSearchQueryState.Tag.toSearchQueryInternal(): SearchQuery.Tag? =
|
||||||
|
if (namespace.value != null || tag.value.isNotBlank()) SearchQuery.Tag(
|
||||||
|
namespace.value,
|
||||||
|
tag.value.lowercase().trim()
|
||||||
|
) else null
|
||||||
|
|
||||||
|
private fun EditableSearchQueryState.And.toSearchQueryInternal(): SearchQuery.And? =
|
||||||
|
queries.mapNotNull { it.toSearchQueryInternal() }
|
||||||
|
.let { if (it.isNotEmpty()) SearchQuery.And(it) else null }
|
||||||
|
|
||||||
|
private fun EditableSearchQueryState.Or.toSearchQueryInternal(): SearchQuery.Or? =
|
||||||
|
queries.mapNotNull { it.toSearchQueryInternal() }
|
||||||
|
.let { if (it.isNotEmpty()) SearchQuery.Or(it) else null }
|
||||||
|
|
||||||
|
private fun EditableSearchQueryState.Not.toSearchQueryInternal(): SearchQuery.Not? =
|
||||||
|
query.value?.toSearchQueryInternal()?.let { SearchQuery.Not(it) }
|
||||||
|
|
||||||
|
private fun EditableSearchQueryState.toSearchQueryInternal(): SearchQuery? = when (this) {
|
||||||
|
is EditableSearchQueryState.Tag -> this.toSearchQueryInternal()
|
||||||
|
is EditableSearchQueryState.And -> this.toSearchQueryInternal()
|
||||||
|
is EditableSearchQueryState.Or -> this.toSearchQueryInternal()
|
||||||
|
is EditableSearchQueryState.Not -> this.toSearchQueryInternal()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun EditableSearchQueryState.Root.toSearchQuery(): SearchQuery? =
|
||||||
|
query.value?.toSearchQueryInternal()
|
||||||
|
|
||||||
|
fun coalesceTags(
|
||||||
|
oldTag: EditableSearchQueryState.Tag?,
|
||||||
|
newTag: EditableSearchQueryState?,
|
||||||
|
): EditableSearchQueryState? = if (oldTag != null) {
|
||||||
|
when (newTag) {
|
||||||
|
is EditableSearchQueryState.Tag,
|
||||||
|
is EditableSearchQueryState.Not,
|
||||||
|
-> EditableSearchQueryState.And(listOf(oldTag, newTag))
|
||||||
|
|
||||||
|
is EditableSearchQueryState.And -> newTag.apply { queries.add(oldTag) }
|
||||||
|
is EditableSearchQueryState.Or -> newTag.apply { queries.add(oldTag) }
|
||||||
|
null -> oldTag
|
||||||
|
}
|
||||||
|
} else newTag
|
||||||
|
|
||||||
|
sealed interface EditableSearchQueryState {
|
||||||
|
class Tag(
|
||||||
|
namespace: String? = null,
|
||||||
|
tag: String = "",
|
||||||
|
expanded: Boolean = false,
|
||||||
|
) : EditableSearchQueryState {
|
||||||
|
val namespace = mutableStateOf(namespace)
|
||||||
|
val tag = mutableStateOf(tag)
|
||||||
|
val expanded = mutableStateOf(expanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
class And(
|
||||||
|
queries: List<EditableSearchQueryState> = emptyList(),
|
||||||
|
) : EditableSearchQueryState {
|
||||||
|
val queries = queries.toMutableStateList()
|
||||||
|
}
|
||||||
|
|
||||||
|
class Or(
|
||||||
|
queries: List<EditableSearchQueryState> = emptyList(),
|
||||||
|
) : EditableSearchQueryState {
|
||||||
|
val queries = queries.toMutableStateList()
|
||||||
|
}
|
||||||
|
|
||||||
|
class Not(
|
||||||
|
query: EditableSearchQueryState? = null,
|
||||||
|
) : EditableSearchQueryState {
|
||||||
|
val query = mutableStateOf(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Root(
|
||||||
|
query: EditableSearchQueryState? = null,
|
||||||
|
) {
|
||||||
|
val query = mutableStateOf(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TagSuggestionList(
|
||||||
|
state: EditableSearchQueryState.Tag,
|
||||||
|
) {
|
||||||
|
var suggestionList: List<Suggestion>? by remember { mutableStateOf(null) }
|
||||||
|
|
||||||
|
var namespace by state.namespace
|
||||||
|
var tag by state.tag
|
||||||
|
var expanded by state.expanded
|
||||||
|
|
||||||
|
LaunchedEffect(namespace, tag) {
|
||||||
|
suggestionList = null
|
||||||
|
|
||||||
|
val searchQuery = state.toSearchQueryInternal()
|
||||||
|
|
||||||
|
suggestionList = if (searchQuery != null) {
|
||||||
|
HitomiHttpClient.getSuggestionsForQuery(searchQuery)
|
||||||
|
.getOrDefault(emptyList())
|
||||||
|
.filterNot { it.tag == SearchQuery.Tag(namespace, tag) }
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val suggestionListSnapshot = suggestionList
|
||||||
|
if (suggestionListSnapshot == null) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(Modifier.size(24.dp))
|
||||||
|
Text("Loading")
|
||||||
|
}
|
||||||
|
} else if (suggestionListSnapshot.isNotEmpty()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
suggestionListSnapshot.forEach { suggestion ->
|
||||||
|
TagChip(
|
||||||
|
tag = suggestion.tag,
|
||||||
|
onClick = {
|
||||||
|
namespace = it.namespace
|
||||||
|
tag = it.tag
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EditableTagChip(
|
||||||
|
state: EditableSearchQueryState.Tag,
|
||||||
|
isFavorite: Boolean = false,
|
||||||
|
autoFocus: Boolean = true,
|
||||||
|
requestScrollTo: (Float) -> Unit,
|
||||||
|
leftIcon: @Composable RowScope.(SearchQuery.Tag) -> Unit = { tag -> TagChipIcon(tag) },
|
||||||
|
rightIcon: @Composable RowScope.(SearchQuery.Tag) -> Unit = { _ -> Spacer(Modifier.width(16.dp)) },
|
||||||
|
content: @Composable RowScope.(SearchQuery.Tag) -> Unit = { tag ->
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f, fill = false)
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
|
text = tag.tag.ifBlank { stringResource(R.string.search_bar_edit_tag) }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var namespace by state.namespace
|
||||||
|
var tag by state.tag
|
||||||
|
var expanded by state.expanded
|
||||||
|
var wasFocused by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
var positionY by remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
|
LaunchedEffect(expanded) {
|
||||||
|
if (!expanded) {
|
||||||
|
wasFocused = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val surfaceColor by animateColorAsState(
|
||||||
|
when {
|
||||||
|
expanded -> MaterialTheme.colorScheme.surface
|
||||||
|
isFavorite -> Yellow400
|
||||||
|
namespace == "male" -> Blue600
|
||||||
|
namespace == "female" -> Pink600
|
||||||
|
else -> MaterialTheme.colorScheme.surface
|
||||||
|
}, label = "tag surface color"
|
||||||
|
)
|
||||||
|
|
||||||
|
val contentColor by animateColorAsState(
|
||||||
|
when {
|
||||||
|
expanded -> Color.White
|
||||||
|
isFavorite -> Color.White
|
||||||
|
namespace == "male" -> Color.White
|
||||||
|
namespace == "female" -> Color.White
|
||||||
|
else -> MaterialTheme.colorScheme.onSurface
|
||||||
|
}, label = "tag content color"
|
||||||
|
)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.onGloballyPositioned {
|
||||||
|
positionY = it.positionInRoot().y
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = surfaceColor,
|
||||||
|
shadowElevation = 4.dp
|
||||||
|
) {
|
||||||
|
AnimatedContent(targetState = expanded, label = "open tag editor") { targetExpanded ->
|
||||||
|
if (!targetExpanded) {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalContentColor provides contentColor,
|
||||||
|
LocalTextStyle provides MaterialTheme.typography.bodyMedium
|
||||||
|
) {
|
||||||
|
val queryTag = SearchQuery.Tag(namespace, tag)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(32.dp)
|
||||||
|
.clickable { expanded = true },
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
leftIcon(queryTag)
|
||||||
|
content(queryTag)
|
||||||
|
rightIcon(queryTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp, bottom = 8.dp, end = 8.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "close tag editor"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var selection by remember(tag) { mutableStateOf(TextRange(tag.length)) }
|
||||||
|
var composition by remember { mutableStateOf<TextRange?>(null) }
|
||||||
|
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
val textFieldValue = remember(tag, selection, composition) {
|
||||||
|
TextFieldValue(tag, selection, composition)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(expanded) {
|
||||||
|
if (autoFocus && expanded) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = textFieldValue,
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
autoCorrectEnabled = false,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
leadingIcon = {
|
||||||
|
TagChipIcon(SearchQuery.Tag(namespace, tag))
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onKeyEvent { event ->
|
||||||
|
if (event.key == Key.Backspace && tag.isEmpty()) {
|
||||||
|
val newTag = namespace?.dropLast(1) ?: ""
|
||||||
|
namespace = null
|
||||||
|
tag = newTag
|
||||||
|
selection = TextRange(newTag.length)
|
||||||
|
composition = null
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
}
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.onFocusChanged { event ->
|
||||||
|
if (event.isFocused) {
|
||||||
|
wasFocused = true
|
||||||
|
coroutineScope.launch {
|
||||||
|
delay(300)
|
||||||
|
requestScrollTo(positionY)
|
||||||
|
}
|
||||||
|
} else if (wasFocused) {
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
expanded = false
|
||||||
|
}
|
||||||
|
),
|
||||||
|
onValueChange = { newTextValue ->
|
||||||
|
val newTag = newTextValue.text
|
||||||
|
val possibleNamespace = newTag.dropLast(1).lowercase().trim()
|
||||||
|
tag =
|
||||||
|
if (namespace == null && newTag.endsWith(':') && possibleNamespace in validNamespace) {
|
||||||
|
namespace = possibleNamespace
|
||||||
|
""
|
||||||
|
} else newTag
|
||||||
|
selection = newTextValue.selection
|
||||||
|
composition = newTextValue.composition
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
TagSuggestionList(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NewQueryChip(
|
||||||
|
currentQuery: EditableSearchQueryState?,
|
||||||
|
onNewQuery: (EditableSearchQueryState) -> Unit,
|
||||||
|
) {
|
||||||
|
var opened by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NewQueryRow(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
icon: ImageVector = Icons.Default.AddCircleOutline,
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.height(32.dp)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.size(16.dp),
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = text
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
modifier = Modifier.padding(end = 16.dp),
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(shape = RoundedCornerShape(16.dp), shadowElevation = 4.dp) {
|
||||||
|
AnimatedContent(targetState = opened, label = "add new query") { targetOpened ->
|
||||||
|
if (targetOpened) {
|
||||||
|
Column {
|
||||||
|
NewQueryRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
icon = Icons.Default.RemoveCircleOutline,
|
||||||
|
text = stringResource(android.R.string.cancel)
|
||||||
|
) {
|
||||||
|
opened = false
|
||||||
|
}
|
||||||
|
HorizontalDivider()
|
||||||
|
if (currentQuery != null && currentQuery !is EditableSearchQueryState.Tag && currentQuery !is EditableSearchQueryState.And) {
|
||||||
|
NewQueryRow(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
text = stringResource(R.string.search_add_query_item_tag)
|
||||||
|
) {
|
||||||
|
opened = false
|
||||||
|
onNewQuery(EditableSearchQueryState.Tag(expanded = true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentQuery !is EditableSearchQueryState.And) {
|
||||||
|
HorizontalDivider()
|
||||||
|
NewQueryRow(modifier = Modifier.fillMaxWidth(), text = "AND") {
|
||||||
|
opened = false
|
||||||
|
onNewQuery(EditableSearchQueryState.And())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentQuery !is EditableSearchQueryState.Or) {
|
||||||
|
HorizontalDivider()
|
||||||
|
NewQueryRow(modifier = Modifier.fillMaxWidth(), text = "OR") {
|
||||||
|
opened = false
|
||||||
|
onNewQuery(EditableSearchQueryState.Or())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentQuery !is EditableSearchQueryState.Not || currentQuery.query.value != null) {
|
||||||
|
HorizontalDivider()
|
||||||
|
NewQueryRow(modifier = Modifier.fillMaxWidth(), text = "NOT") {
|
||||||
|
opened = false
|
||||||
|
onNewQuery(EditableSearchQueryState.Not())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NewQueryRow(text = stringResource(R.string.search_add_query_item)) {
|
||||||
|
opened = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun QueryEditorQueryView(
|
||||||
|
state: EditableSearchQueryState,
|
||||||
|
onQueryRemove: (EditableSearchQueryState) -> Unit,
|
||||||
|
requestScrollTo: (Float) -> Unit,
|
||||||
|
requestScrollBy: (Float) -> Unit,
|
||||||
|
) {
|
||||||
|
when (state) {
|
||||||
|
is EditableSearchQueryState.Tag -> {
|
||||||
|
EditableTagChip(
|
||||||
|
state,
|
||||||
|
requestScrollTo = requestScrollTo,
|
||||||
|
rightIcon = {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.size(16.dp)
|
||||||
|
.clickable {
|
||||||
|
onQueryRemove(state)
|
||||||
|
},
|
||||||
|
imageVector = Icons.Default.RemoveCircleOutline,
|
||||||
|
contentDescription = stringResource(R.string.search_remove_query_item_description)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is EditableSearchQueryState.Or -> {
|
||||||
|
Card(
|
||||||
|
colors = CardColors(
|
||||||
|
containerColor = Blue300,
|
||||||
|
contentColor = Color.Black,
|
||||||
|
disabledContainerColor = Blue300,
|
||||||
|
disabledContentColor = Color.Black
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"OR",
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(16.dp)
|
||||||
|
.clickable { onQueryRemove(state) },
|
||||||
|
imageVector = Icons.Default.RemoveCircleOutline,
|
||||||
|
contentDescription = stringResource(R.string.search_remove_query_item_description)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state.queries.forEachIndexed { index, subQueryState ->
|
||||||
|
if (index != 0) {
|
||||||
|
Text("+", modifier = Modifier.padding(horizontal = 8.dp))
|
||||||
|
}
|
||||||
|
QueryEditorQueryView(
|
||||||
|
subQueryState,
|
||||||
|
onQueryRemove = { state.queries.remove(it) },
|
||||||
|
requestScrollTo = requestScrollTo,
|
||||||
|
requestScrollBy = requestScrollBy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
NewQueryChip(state) { newQueryState ->
|
||||||
|
state.queries.add(newQueryState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is EditableSearchQueryState.And -> {
|
||||||
|
Card(
|
||||||
|
colors = CardColors(
|
||||||
|
containerColor = Gray300,
|
||||||
|
contentColor = Color.Black,
|
||||||
|
disabledContainerColor = Gray300,
|
||||||
|
disabledContentColor = Color.Black
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
) {
|
||||||
|
val newSearchQuery = remember { EditableSearchQueryState.Tag() }
|
||||||
|
|
||||||
|
var newQueryNamespace by newSearchQuery.namespace
|
||||||
|
var newQueryTag by newSearchQuery.tag
|
||||||
|
var newQueryExpanded by newSearchQuery.expanded
|
||||||
|
|
||||||
|
val offset = with(LocalDensity.current) { 40.dp.toPx() }
|
||||||
|
|
||||||
|
LaunchedEffect(newQueryExpanded) {
|
||||||
|
if (!newQueryExpanded && (newQueryNamespace != null || newQueryTag.isNotBlank())) {
|
||||||
|
state.queries.add(
|
||||||
|
EditableSearchQueryState.Tag(
|
||||||
|
newQueryNamespace,
|
||||||
|
newQueryTag
|
||||||
|
)
|
||||||
|
)
|
||||||
|
newQueryNamespace = null
|
||||||
|
newQueryTag = ""
|
||||||
|
newQueryExpanded = true
|
||||||
|
requestScrollBy(offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"AND",
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(16.dp)
|
||||||
|
.clickable { onQueryRemove(state) },
|
||||||
|
imageVector = Icons.Default.RemoveCircleOutline,
|
||||||
|
contentDescription = stringResource(R.string.search_remove_query_item_description)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state.queries.forEach { subQuery ->
|
||||||
|
QueryEditorQueryView(
|
||||||
|
subQuery,
|
||||||
|
onQueryRemove = { state.queries.remove(it) },
|
||||||
|
requestScrollTo = requestScrollTo,
|
||||||
|
requestScrollBy = requestScrollBy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
EditableTagChip(
|
||||||
|
newSearchQuery,
|
||||||
|
requestScrollTo = requestScrollTo,
|
||||||
|
)
|
||||||
|
NewQueryChip(state) { newQueryState ->
|
||||||
|
state.queries.add(newQueryState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is EditableSearchQueryState.Not -> {
|
||||||
|
var subQueryState by state.query
|
||||||
|
|
||||||
|
Card(
|
||||||
|
colors = CardColors(
|
||||||
|
containerColor = Red300,
|
||||||
|
contentColor = Color.Black,
|
||||||
|
disabledContainerColor = Red300,
|
||||||
|
disabledContentColor = Color.Black
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"-",
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(16.dp)
|
||||||
|
.clickable { onQueryRemove(state) },
|
||||||
|
imageVector = Icons.Default.RemoveCircleOutline,
|
||||||
|
contentDescription = stringResource(R.string.search_remove_query_item_description)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val subQueryStateSnapshot = subQueryState
|
||||||
|
if (subQueryStateSnapshot != null) {
|
||||||
|
QueryEditorQueryView(
|
||||||
|
subQueryStateSnapshot,
|
||||||
|
onQueryRemove = { subQueryState = null },
|
||||||
|
requestScrollTo = requestScrollTo,
|
||||||
|
requestScrollBy = requestScrollBy,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subQueryStateSnapshot == null) {
|
||||||
|
NewQueryChip(state) { newQueryState ->
|
||||||
|
subQueryState = newQueryState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subQueryStateSnapshot is EditableSearchQueryState.Tag) {
|
||||||
|
NewQueryChip(state) { newQueryState ->
|
||||||
|
subQueryState = coalesceTags(subQueryStateSnapshot, newQueryState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun QueryEditor(
|
||||||
|
state: EditableSearchQueryState.Root,
|
||||||
|
) {
|
||||||
|
var rootQuery by state.query
|
||||||
|
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
var topY by remember { mutableFloatStateOf(0f) }
|
||||||
|
|
||||||
|
val scrollOffset = with(LocalDensity.current) { 16.dp.toPx() }
|
||||||
|
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.onGloballyPositioned {
|
||||||
|
topY = it.positionInRoot().y
|
||||||
|
}
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
val rootQuerySnapshot = rootQuery
|
||||||
|
|
||||||
|
val requestScrollTo: (Float) -> Unit = { target ->
|
||||||
|
val topYSnapshot = topY
|
||||||
|
|
||||||
|
coroutineScope.launch {
|
||||||
|
scrollState.animateScrollBy(
|
||||||
|
target - topYSnapshot - scrollOffset,
|
||||||
|
spring(stiffness = Spring.StiffnessLow)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val requestScrollBy: (Float) -> Unit = { value ->
|
||||||
|
coroutineScope.launch {
|
||||||
|
scrollState.animateScrollBy(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rootQuerySnapshot != null) {
|
||||||
|
QueryEditorQueryView(
|
||||||
|
state = rootQuerySnapshot,
|
||||||
|
onQueryRemove = { rootQuery = null },
|
||||||
|
requestScrollTo = requestScrollTo,
|
||||||
|
requestScrollBy = requestScrollBy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rootQuerySnapshot is EditableSearchQueryState.Tag?) {
|
||||||
|
val newSearchQuery = remember { EditableSearchQueryState.Tag(expanded = true) }
|
||||||
|
|
||||||
|
var newQueryNamespace by newSearchQuery.namespace
|
||||||
|
var newQueryTag by newSearchQuery.tag
|
||||||
|
var newQueryExpanded by newSearchQuery.expanded
|
||||||
|
|
||||||
|
val offset = with(LocalDensity.current) { 40.dp.toPx() }
|
||||||
|
|
||||||
|
LaunchedEffect(newQueryExpanded) {
|
||||||
|
if (!newQueryExpanded && (newQueryNamespace != null || newQueryTag.isNotBlank())) {
|
||||||
|
rootQuery = if (rootQuerySnapshot == null) {
|
||||||
|
EditableSearchQueryState.Tag(newQueryNamespace, newQueryTag)
|
||||||
|
} else {
|
||||||
|
EditableSearchQueryState.And(
|
||||||
|
listOf(
|
||||||
|
rootQuerySnapshot,
|
||||||
|
EditableSearchQueryState.Tag(newQueryNamespace, newQueryTag)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
newQueryNamespace = null
|
||||||
|
newQueryTag = ""
|
||||||
|
newQueryExpanded = true
|
||||||
|
requestScrollBy(offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EditableTagChip(
|
||||||
|
newSearchQuery,
|
||||||
|
requestScrollTo = requestScrollTo
|
||||||
|
)
|
||||||
|
NewQueryChip(rootQuerySnapshot) { newState ->
|
||||||
|
rootQuery = coalesceTags(rootQuerySnapshot, newState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
703
app/src/main/java/xyz/quaver/pupil/ui/composable/SearchScreen.kt
Normal file
@@ -0,0 +1,703 @@
|
|||||||
|
package xyz.quaver.pupil.ui.composable
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.AnimationState
|
||||||
|
import androidx.compose.animation.core.VectorConverter
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.animateTo
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.absoluteOffset
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
|
import androidx.compose.foundation.layout.calculateEndPadding
|
||||||
|
import androidx.compose.foundation.layout.calculateStartPadding
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.systemBars
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsTopHeight
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Label
|
||||||
|
import androidx.compose.material.icons.filled.Book
|
||||||
|
import androidx.compose.material.icons.filled.Brush
|
||||||
|
import androidx.compose.material.icons.filled.Face
|
||||||
|
import androidx.compose.material.icons.filled.Female
|
||||||
|
import androidx.compose.material.icons.filled.Folder
|
||||||
|
import androidx.compose.material.icons.filled.Group
|
||||||
|
import androidx.compose.material.icons.filled.Male
|
||||||
|
import androidx.compose.material.icons.filled.Translate
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
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.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.window.layout.DisplayFeature
|
||||||
|
import com.google.accompanist.adaptive.HorizontalTwoPaneStrategy
|
||||||
|
import com.google.accompanist.adaptive.TwoPane
|
||||||
|
import xyz.quaver.pupil.R
|
||||||
|
import xyz.quaver.pupil.networking.GalleryInfo
|
||||||
|
import xyz.quaver.pupil.networking.HitomiHttpClient
|
||||||
|
import xyz.quaver.pupil.networking.SearchQuery
|
||||||
|
import xyz.quaver.pupil.ui.theme.Blue600
|
||||||
|
import xyz.quaver.pupil.ui.theme.Pink600
|
||||||
|
import xyz.quaver.pupil.ui.theme.Yellow400
|
||||||
|
import xyz.quaver.pupil.ui.viewmodel.SearchState
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private val iconMap = mapOf(
|
||||||
|
"female" to Icons.Default.Female,
|
||||||
|
"male" to Icons.Default.Male,
|
||||||
|
"artist" to Icons.Default.Brush,
|
||||||
|
"group" to Icons.Default.Group,
|
||||||
|
"character" to Icons.Default.Face,
|
||||||
|
"series" to Icons.Default.Book,
|
||||||
|
"type" to Icons.Default.Folder,
|
||||||
|
"language" to Icons.Default.Translate,
|
||||||
|
"tag" to Icons.AutoMirrored.Filled.Label,
|
||||||
|
)
|
||||||
|
|
||||||
|
val languageIconMap = mapOf(
|
||||||
|
"indonesian" to R.drawable.language_indonesian,
|
||||||
|
"javanese" to R.drawable.language_javanese,
|
||||||
|
"catalan" to R.drawable.language_catalan,
|
||||||
|
"cebuano" to R.drawable.language_philippines,
|
||||||
|
"czech" to R.drawable.language_czech,
|
||||||
|
"danish" to R.drawable.language_danish,
|
||||||
|
"german" to R.drawable.language_german,
|
||||||
|
"estonian" to R.drawable.language_estonian,
|
||||||
|
"english" to R.drawable.language_english,
|
||||||
|
"spanish" to R.drawable.language_spanish,
|
||||||
|
"french" to R.drawable.language_french,
|
||||||
|
"italian" to R.drawable.language_italian,
|
||||||
|
"latin" to R.drawable.language_latin,
|
||||||
|
"hungarian" to R.drawable.language_hungarian,
|
||||||
|
"dutch" to R.drawable.language_dutch,
|
||||||
|
"norwegian" to R.drawable.language_norwegian,
|
||||||
|
"polish" to R.drawable.language_polish,
|
||||||
|
"portuguese" to R.drawable.language_portuguese,
|
||||||
|
"romanian" to R.drawable.language_romanian,
|
||||||
|
"albanian" to R.drawable.language_albanian,
|
||||||
|
"slovak" to R.drawable.language_slovak,
|
||||||
|
"finnish" to R.drawable.language_finnish,
|
||||||
|
"swedish" to R.drawable.language_swedish,
|
||||||
|
"tagalog" to R.drawable.language_philippines,
|
||||||
|
"vietnamese" to R.drawable.language_vietnamese,
|
||||||
|
"turkish" to R.drawable.language_turkish,
|
||||||
|
"greek" to R.drawable.language_greek,
|
||||||
|
"mongolian" to R.drawable.language_mongolian,
|
||||||
|
"russian" to R.drawable.language_russian,
|
||||||
|
"ukrainian" to R.drawable.language_ukrainian,
|
||||||
|
"hebrew" to R.drawable.language_hebrew,
|
||||||
|
"persian" to R.drawable.language_persian,
|
||||||
|
"thai" to R.drawable.language_thai,
|
||||||
|
"korean" to R.drawable.language_korean,
|
||||||
|
"chinese" to R.drawable.language_chinese,
|
||||||
|
"japanese" to R.drawable.language_japanese,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TagChipIcon(tag: SearchQuery.Tag) {
|
||||||
|
val icon = iconMap[tag.namespace]
|
||||||
|
|
||||||
|
if (icon != null) {
|
||||||
|
if (tag.namespace == "language" && languageIconMap.contains(tag.tag)) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(languageIconMap[tag.tag]!!),
|
||||||
|
contentDescription = "icon",
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.size(24.dp),
|
||||||
|
tint = Color.Unspecified
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = "icon",
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TagChip(
|
||||||
|
tag: SearchQuery.Tag,
|
||||||
|
isFavorite: Boolean = false,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onClick: (SearchQuery.Tag) -> Unit = { },
|
||||||
|
leftIcon: @Composable (SearchQuery.Tag) -> Unit = { TagChipIcon(it) },
|
||||||
|
rightIcon: @Composable (SearchQuery.Tag) -> Unit = { Spacer(Modifier.width(16.dp)) },
|
||||||
|
content: @Composable RowScope.(SearchQuery.Tag) -> Unit = {
|
||||||
|
Text(
|
||||||
|
it.tag,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f, fill = false)
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
val surfaceColor = if (isFavorite) Yellow400 else when (tag.namespace) {
|
||||||
|
"male" -> Blue600
|
||||||
|
"female" -> Pink600
|
||||||
|
else -> MaterialTheme.colorScheme.surface
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentColor =
|
||||||
|
if (surfaceColor == MaterialTheme.colorScheme.surface)
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
|
else
|
||||||
|
Color.White
|
||||||
|
|
||||||
|
val inner = @Composable {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalContentColor provides contentColor,
|
||||||
|
LocalTextStyle provides MaterialTheme.typography.bodyMedium
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
leftIcon(tag)
|
||||||
|
content(tag)
|
||||||
|
rightIcon(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val modifier = Modifier.height(32.dp)
|
||||||
|
val shape = RoundedCornerShape(16.dp)
|
||||||
|
|
||||||
|
if (enabled)
|
||||||
|
Surface(
|
||||||
|
modifier = modifier,
|
||||||
|
shape = shape,
|
||||||
|
color = surfaceColor,
|
||||||
|
onClick = { onClick(tag) },
|
||||||
|
content = inner,
|
||||||
|
shadowElevation = 4.dp
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Surface(
|
||||||
|
modifier,
|
||||||
|
shape = shape,
|
||||||
|
color = surfaceColor,
|
||||||
|
content = inner,
|
||||||
|
shadowElevation = 4.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun QueryView(
|
||||||
|
query: SearchQuery?,
|
||||||
|
topLevel: Boolean = true,
|
||||||
|
) {
|
||||||
|
val modifier = if (topLevel) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier.border(
|
||||||
|
width = 0.5.dp,
|
||||||
|
color = LocalContentColor.current,
|
||||||
|
shape = CardDefaults.shape
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (query) {
|
||||||
|
null -> {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(60.dp)
|
||||||
|
.wrapContentHeight()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
text = stringResource(id = R.string.search_hint),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SearchQuery.Tag -> {
|
||||||
|
TagChip(
|
||||||
|
query,
|
||||||
|
enabled = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SearchQuery.Or -> {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.padding(vertical = 2.dp, horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
query.queries.forEachIndexed { index, subQuery ->
|
||||||
|
if (index != 0) {
|
||||||
|
Text("+")
|
||||||
|
}
|
||||||
|
QueryView(subQuery, topLevel = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is SearchQuery.And -> {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
query.queries.forEach { subQuery ->
|
||||||
|
QueryView(subQuery, topLevel = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is SearchQuery.Not -> {
|
||||||
|
Row(
|
||||||
|
modifier = modifier.padding(vertical = 2.dp, horizontal = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("-")
|
||||||
|
QueryView(query.query, topLevel = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SearchBar(
|
||||||
|
contentType: ContentType,
|
||||||
|
query: SearchQuery?,
|
||||||
|
onQueryChange: (SearchQuery?) -> Unit,
|
||||||
|
onSearchBarPositioned: (Int) -> Unit,
|
||||||
|
topOffset: Int,
|
||||||
|
onTopOffsetChange: (Int) -> Unit,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
var focused by remember { mutableStateOf(false) }
|
||||||
|
val scrimAlpha: Float by animateFloatAsState(
|
||||||
|
if (focused && contentType == ContentType.SINGLE_PANE) 0.3f else 0f,
|
||||||
|
label = "scrim alpha"
|
||||||
|
)
|
||||||
|
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
|
||||||
|
val state = remember(query) { query.toEditableState() }
|
||||||
|
|
||||||
|
LaunchedEffect(focused) {
|
||||||
|
if (!focused) {
|
||||||
|
onQueryChange(state.toSearchQuery())
|
||||||
|
} else {
|
||||||
|
AnimationState(Int.VectorConverter, topOffset).animateTo(0) { onTopOffsetChange(value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (focused) {
|
||||||
|
BackHandler {
|
||||||
|
focused = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clickable(
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = null
|
||||||
|
) {
|
||||||
|
focused = false
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val height: Dp by animateDpAsState(
|
||||||
|
if (focused) maxHeight else 60.dp,
|
||||||
|
label = "searchbar height"
|
||||||
|
)
|
||||||
|
val cardShape = RoundedCornerShape(30.dp)
|
||||||
|
|
||||||
|
content()
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = scrimAlpha))
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = null
|
||||||
|
) {
|
||||||
|
focused = true
|
||||||
|
}
|
||||||
|
.onGloballyPositioned {
|
||||||
|
onSearchBarPositioned(it.positionInRoot().y.roundToInt() + it.size.height)
|
||||||
|
}
|
||||||
|
.absoluteOffset { IntOffset(0, topOffset) },
|
||||||
|
shape = cardShape,
|
||||||
|
elevation = CardDefaults.cardElevation(6.dp)
|
||||||
|
) {
|
||||||
|
Box {
|
||||||
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
!focused,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(min = 60.dp)
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(8.dp))
|
||||||
|
QueryView(query)
|
||||||
|
Box(Modifier.size(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
focused,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = 8.dp, start = 8.dp, end = 8.dp)
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
focused = false
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "close search bar"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
QueryEditor(state = state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GalleryList(
|
||||||
|
contentType: ContentType,
|
||||||
|
galleries: List<GalleryInfo>,
|
||||||
|
query: SearchQuery?,
|
||||||
|
currentPage: Int,
|
||||||
|
maxPage: Int,
|
||||||
|
loading: Boolean = false,
|
||||||
|
error: Boolean = false,
|
||||||
|
onPageChange: (Int) -> Unit,
|
||||||
|
onQueryChange: (SearchQuery?) -> Unit = {},
|
||||||
|
openGalleryDetails: (GalleryInfo) -> Unit,
|
||||||
|
) {
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
var topOffset by remember { mutableIntStateOf(0) }
|
||||||
|
var searchBarPosition by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
val listModifier = Modifier.nestedScroll(object : NestedScrollConnection {
|
||||||
|
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||||
|
topOffset = (topOffset + available.y.roundToInt()).coerceIn(-searchBarPosition, 0)
|
||||||
|
return Offset.Zero
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
LaunchedEffect(galleries) {
|
||||||
|
listState.animateScrollToItem(0)
|
||||||
|
topOffset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
SearchBar(
|
||||||
|
contentType = contentType,
|
||||||
|
query = query,
|
||||||
|
onQueryChange = onQueryChange,
|
||||||
|
onSearchBarPositioned = { searchBarPosition = it },
|
||||||
|
topOffset = topOffset,
|
||||||
|
onTopOffsetChange = { topOffset = it },
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(loading, enter = fadeIn(), exit = fadeOut()) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AnimatedVisibility(error, enter = fadeIn(), exit = fadeOut()) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Column(
|
||||||
|
Modifier.align(Alignment.Center),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"(´∇`)",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
Text("No sources found!\nLet's go download one.", textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AnimatedVisibility(!loading && !error, enter = fadeIn(), exit = fadeOut()) {
|
||||||
|
OverscrollPager(
|
||||||
|
prevPage = if (currentPage != 0) currentPage else null,
|
||||||
|
nextPage = if (currentPage < maxPage) currentPage + 2 else null,
|
||||||
|
onPageTurn = { onPageChange(it - 1) }
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = listModifier,
|
||||||
|
contentPadding = WindowInsets.systemBars.asPaddingValues()
|
||||||
|
.let { systemBarPaddingValues ->
|
||||||
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
|
PaddingValues(
|
||||||
|
top = systemBarPaddingValues.calculateTopPadding() + 96.dp,
|
||||||
|
bottom = systemBarPaddingValues.calculateBottomPadding(),
|
||||||
|
start = systemBarPaddingValues.calculateStartPadding(layoutDirection),
|
||||||
|
end = systemBarPaddingValues.calculateEndPadding(layoutDirection),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
state = listState
|
||||||
|
) {
|
||||||
|
items(galleries, key = { it.id }) { galleryInfo ->
|
||||||
|
DetailedGalleryInfo(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.clickable { openGalleryDetails(galleryInfo) },
|
||||||
|
galleryInfo = galleryInfo
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DetailScreen(
|
||||||
|
galleryInfo: GalleryInfo,
|
||||||
|
closeGalleryDetails: () -> Unit = { },
|
||||||
|
openGallery: (GalleryInfo) -> Unit = { },
|
||||||
|
) {
|
||||||
|
var thumbnailUrl by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
LaunchedEffect(galleryInfo) {
|
||||||
|
thumbnailUrl = galleryInfo.files.firstOrNull()?.let {
|
||||||
|
HitomiHttpClient.getImageURL(it, true).firstOrNull()
|
||||||
|
} ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.systemBars))
|
||||||
|
IconButton(onClick = closeGalleryDetails) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close Detail")
|
||||||
|
}
|
||||||
|
|
||||||
|
DetailedGalleryInfoHeader(galleryInfo, thumbnailUrl)
|
||||||
|
|
||||||
|
Row(Modifier.fillMaxWidth()) {
|
||||||
|
FilledTonalButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 4.dp),
|
||||||
|
onClick = { /*TODO*/ }
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.download))
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 4.dp),
|
||||||
|
onClick = { openGallery(galleryInfo) }
|
||||||
|
) {
|
||||||
|
Text("Open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GalleryTypeIndicator(galleryInfo.type)
|
||||||
|
|
||||||
|
if (galleryInfo.series?.isNotEmpty() == true) {
|
||||||
|
TagGroup(galleryInfo.series.map { it.toTag() })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (galleryInfo.characters?.isNotEmpty() == true) {
|
||||||
|
TagGroup(galleryInfo.characters.map { it.toTag() })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (galleryInfo.tags?.isNotEmpty() == true) {
|
||||||
|
TagGroup(galleryInfo.tags.map { it.toTag() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SearchScreen(
|
||||||
|
contentType: ContentType,
|
||||||
|
displayFeatures: List<DisplayFeature>,
|
||||||
|
uiState: SearchState,
|
||||||
|
openGalleryDetails: (GalleryInfo) -> Unit,
|
||||||
|
closeGalleryDetails: () -> Unit,
|
||||||
|
onQueryChange: (SearchQuery?) -> Unit,
|
||||||
|
loadSearchResult: (IntRange) -> Unit,
|
||||||
|
openGallery: (GalleryInfo) -> Unit,
|
||||||
|
) {
|
||||||
|
val itemsPerPage by remember { mutableIntStateOf(20) }
|
||||||
|
|
||||||
|
val pageToRange: (Int) -> IntRange = remember(itemsPerPage) {
|
||||||
|
{ page ->
|
||||||
|
page * itemsPerPage..<(page + 1) * itemsPerPage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentPage = remember(uiState) {
|
||||||
|
if (uiState.currentRange != IntRange.EMPTY) {
|
||||||
|
uiState.currentRange.first / itemsPerPage
|
||||||
|
} else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val maxPage = remember(itemsPerPage, uiState) {
|
||||||
|
if (uiState.galleryCount != null) {
|
||||||
|
uiState.galleryCount / itemsPerPage + if (uiState.galleryCount % itemsPerPage != 0) 1 else 0
|
||||||
|
} else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val loadResult: (Int) -> Unit = remember(loadSearchResult) {
|
||||||
|
{ page ->
|
||||||
|
loadSearchResult(pageToRange(page))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(uiState.query) { loadSearchResult(pageToRange(currentPage)) }
|
||||||
|
|
||||||
|
LaunchedEffect(contentType) {
|
||||||
|
if (contentType == ContentType.SINGLE_PANE && !uiState.isDetailOnlyOpen) {
|
||||||
|
closeGalleryDetails()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType == ContentType.SINGLE_PANE && uiState.isDetailOnlyOpen) {
|
||||||
|
BackHandler {
|
||||||
|
closeGalleryDetails()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType == ContentType.DUAL_PANE) {
|
||||||
|
TwoPane(
|
||||||
|
first = {
|
||||||
|
GalleryList(
|
||||||
|
contentType = contentType,
|
||||||
|
galleries = uiState.galleries,
|
||||||
|
query = uiState.query,
|
||||||
|
currentPage = currentPage,
|
||||||
|
maxPage = maxPage,
|
||||||
|
loading = uiState.loading,
|
||||||
|
error = uiState.error,
|
||||||
|
onQueryChange = onQueryChange,
|
||||||
|
onPageChange = loadResult,
|
||||||
|
openGalleryDetails = openGalleryDetails
|
||||||
|
)
|
||||||
|
},
|
||||||
|
second = {
|
||||||
|
|
||||||
|
},
|
||||||
|
strategy = HorizontalTwoPaneStrategy(splitFraction = 0.5f, gapWidth = 16.dp),
|
||||||
|
displayFeatures = displayFeatures
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val detailGallery = uiState.openedGallery
|
||||||
|
AnimatedVisibility(!uiState.isDetailOnlyOpen || detailGallery == null) {
|
||||||
|
GalleryList(
|
||||||
|
contentType = contentType,
|
||||||
|
galleries = uiState.galleries,
|
||||||
|
query = uiState.query,
|
||||||
|
currentPage = currentPage,
|
||||||
|
maxPage = maxPage,
|
||||||
|
loading = uiState.loading,
|
||||||
|
error = uiState.error,
|
||||||
|
onQueryChange = onQueryChange,
|
||||||
|
onPageChange = loadResult,
|
||||||
|
openGalleryDetails = openGalleryDetails
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AnimatedVisibility(uiState.isDetailOnlyOpen && detailGallery != null) {
|
||||||
|
if (detailGallery != null) {
|
||||||
|
DetailScreen(
|
||||||
|
galleryInfo = detailGallery,
|
||||||
|
closeGalleryDetails = closeGalleryDetails,
|
||||||
|
openGallery = openGallery
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
148
app/src/main/java/xyz/quaver/pupil/ui/theme/Color.kt
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package xyz.quaver.pupil.ui.theme
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
val md_theme_light_primary = Color(0xFF006688)
|
||||||
|
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
|
||||||
|
val md_theme_light_primaryContainer = Color(0xFFC2E8FF)
|
||||||
|
val md_theme_light_onPrimaryContainer = Color(0xFF001E2B)
|
||||||
|
val md_theme_light_secondary = Color(0xFF4E616D)
|
||||||
|
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
|
||||||
|
val md_theme_light_secondaryContainer = Color(0xFFD1E5F3)
|
||||||
|
val md_theme_light_onSecondaryContainer = Color(0xFF091E28)
|
||||||
|
val md_theme_light_tertiary = Color(0xFF5F5A7D)
|
||||||
|
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
|
||||||
|
val md_theme_light_tertiaryContainer = Color(0xFFE5DEFF)
|
||||||
|
val md_theme_light_onTertiaryContainer = Color(0xFF1C1736)
|
||||||
|
val md_theme_light_error = Color(0xFFBA1A1A)
|
||||||
|
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
|
||||||
|
val md_theme_light_onError = Color(0xFFFFFFFF)
|
||||||
|
val md_theme_light_onErrorContainer = Color(0xFF410002)
|
||||||
|
val md_theme_light_background = Color(0xFFFBFCFE)
|
||||||
|
val md_theme_light_onBackground = Color(0xFF191C1E)
|
||||||
|
val md_theme_light_surface = Color(0xFFFBFCFE)
|
||||||
|
val md_theme_light_onSurface = Color(0xFF191C1E)
|
||||||
|
val md_theme_light_surfaceVariant = Color(0xFFDCE3E9)
|
||||||
|
val md_theme_light_onSurfaceVariant = Color(0xFF40484D)
|
||||||
|
val md_theme_light_outline = Color(0xFF71787D)
|
||||||
|
val md_theme_light_inverseOnSurface = Color(0xFFF0F1F3)
|
||||||
|
val md_theme_light_inverseSurface = Color(0xFF2E3133)
|
||||||
|
val md_theme_light_inversePrimary = Color(0xFF75D1FF)
|
||||||
|
val md_theme_light_shadow = Color(0xFF000000)
|
||||||
|
val md_theme_light_surfaceTint = Color(0xFF006688)
|
||||||
|
val md_theme_light_outlineVariant = Color(0xFFC0C7CD)
|
||||||
|
val md_theme_light_scrim = Color(0xFF000000)
|
||||||
|
|
||||||
|
val md_theme_dark_primary = Color(0xFF75D1FF)
|
||||||
|
val md_theme_dark_onPrimary = Color(0xFF003548)
|
||||||
|
val md_theme_dark_primaryContainer = Color(0xFF004D67)
|
||||||
|
val md_theme_dark_onPrimaryContainer = Color(0xFFC2E8FF)
|
||||||
|
val md_theme_dark_secondary = Color(0xFFB5C9D7)
|
||||||
|
val md_theme_dark_onSecondary = Color(0xFF20333D)
|
||||||
|
val md_theme_dark_secondaryContainer = Color(0xFF364954)
|
||||||
|
val md_theme_dark_onSecondaryContainer = Color(0xFFD1E5F3)
|
||||||
|
val md_theme_dark_tertiary = Color(0xFFC9C2EA)
|
||||||
|
val md_theme_dark_onTertiary = Color(0xFF312C4C)
|
||||||
|
val md_theme_dark_tertiaryContainer = Color(0xFF474364)
|
||||||
|
val md_theme_dark_onTertiaryContainer = Color(0xFFE5DEFF)
|
||||||
|
val md_theme_dark_error = Color(0xFFFFB4AB)
|
||||||
|
val md_theme_dark_errorContainer = Color(0xFF93000A)
|
||||||
|
val md_theme_dark_onError = Color(0xFF690005)
|
||||||
|
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
|
||||||
|
val md_theme_dark_background = Color(0xFF191C1E)
|
||||||
|
val md_theme_dark_onBackground = Color(0xFFE1E2E5)
|
||||||
|
val md_theme_dark_surface = Color(0xFF191C1E)
|
||||||
|
val md_theme_dark_onSurface = Color(0xFFE1E2E5)
|
||||||
|
val md_theme_dark_surfaceVariant = Color(0xFF40484D)
|
||||||
|
val md_theme_dark_onSurfaceVariant = Color(0xFFC0C7CD)
|
||||||
|
val md_theme_dark_outline = Color(0xFF8A9297)
|
||||||
|
val md_theme_dark_inverseOnSurface = Color(0xFF191C1E)
|
||||||
|
val md_theme_dark_inverseSurface = Color(0xFFE1E2E5)
|
||||||
|
val md_theme_dark_inversePrimary = Color(0xFF006688)
|
||||||
|
val md_theme_dark_shadow = Color(0xFF000000)
|
||||||
|
val md_theme_dark_surfaceTint = Color(0xFF75D1FF)
|
||||||
|
val md_theme_dark_outlineVariant = Color(0xFF40484D)
|
||||||
|
val md_theme_dark_scrim = Color(0xFF000000)
|
||||||
|
|
||||||
|
|
||||||
|
val seed = Color(0xFF4FC3F7)
|
||||||
|
|
||||||
|
val Gray50 = Color(0xFFF9FAFB)
|
||||||
|
val Gray100 = Color(0xFFF3F4F6)
|
||||||
|
val Gray200 = Color(0xFFE5E7EB)
|
||||||
|
val Gray300 = Color(0xFFD1D5DB)
|
||||||
|
val Gray400 = Color(0xFF9CA3AF)
|
||||||
|
val Gray500 = Color(0xFF6B7280)
|
||||||
|
val Gray600 = Color(0xFF4B5563)
|
||||||
|
val Gray700 = Color(0xFF374151)
|
||||||
|
val Gray800 = Color(0xFF1F2937)
|
||||||
|
val Gray900 = Color(0xFF111827)
|
||||||
|
val Red50 = Color(0xFFFEF2F2)
|
||||||
|
val Red100 = Color(0xFFFEE2E2)
|
||||||
|
val Red200 = Color(0xFFFECACA)
|
||||||
|
val Red300 = Color(0xFFFCA5A5)
|
||||||
|
val Red400 = Color(0xFFF87171)
|
||||||
|
val Red500 = Color(0xFFEF4444)
|
||||||
|
val Red600 = Color(0xFFDC2626)
|
||||||
|
val Red700 = Color(0xFFB91C1C)
|
||||||
|
val Red800 = Color(0xFF991B1B)
|
||||||
|
val Red900 = Color(0xFF7F1D1D)
|
||||||
|
val Yellow50 = Color(0xFFFFFBEB)
|
||||||
|
val Yellow100 = Color(0xFFFEF3C7)
|
||||||
|
val Yellow200 = Color(0xFFFDE68A)
|
||||||
|
val Yellow300 = Color(0xFFFCD34D)
|
||||||
|
val Yellow400 = Color(0xFFFBBF24)
|
||||||
|
val Yellow500 = Color(0xFFF59E0B)
|
||||||
|
val Yellow600 = Color(0xFFD97706)
|
||||||
|
val Yellow700 = Color(0xFFB45309)
|
||||||
|
val Yellow800 = Color(0xFF92400E)
|
||||||
|
val Yellow900 = Color(0xFF78350F)
|
||||||
|
val Green50 = Color(0xFFECFDF5)
|
||||||
|
val Green100 = Color(0xFFD1FAE5)
|
||||||
|
val Green200 = Color(0xFFA7F3D0)
|
||||||
|
val Green300 = Color(0xFF6EE7B7)
|
||||||
|
val Green400 = Color(0xFF34D399)
|
||||||
|
val Green500 = Color(0xFF10B981)
|
||||||
|
val Green600 = Color(0xFF059669)
|
||||||
|
val Green700 = Color(0xFF047857)
|
||||||
|
val Green800 = Color(0xFF065F46)
|
||||||
|
val Green900 = Color(0xFF064E3B)
|
||||||
|
val Blue50 = Color(0xFFEFF6FF)
|
||||||
|
val Blue100 = Color(0xFFDBEAFE)
|
||||||
|
val Blue200 = Color(0xFFBFDBFE)
|
||||||
|
val Blue300 = Color(0xFF93C5FD)
|
||||||
|
val Blue400 = Color(0xFF60A5FA)
|
||||||
|
val Blue500 = Color(0xFF3B82F6)
|
||||||
|
val Blue600 = Color(0xFF2563EB)
|
||||||
|
val Blue700 = Color(0xFF1D4ED8)
|
||||||
|
val Blue800 = Color(0xFF1E40AF)
|
||||||
|
val Blue900 = Color(0xFF1E3A8A)
|
||||||
|
val Indigo50 = Color(0xFFEEF2FF)
|
||||||
|
val Indigo100 = Color(0xFFE0E7FF)
|
||||||
|
val Indigo200 = Color(0xFFC7D2FE)
|
||||||
|
val Indigo300 = Color(0xFFA5B4FC)
|
||||||
|
val Indigo400 = Color(0xFF818CF8)
|
||||||
|
val Indigo500 = Color(0xFF6366F1)
|
||||||
|
val Indigo600 = Color(0xFF4F46E5)
|
||||||
|
val Indigo700 = Color(0xFF4338CA)
|
||||||
|
val Indigo800 = Color(0xFF3730A3)
|
||||||
|
val Indigo900 = Color(0xFF312E81)
|
||||||
|
val Purple50 = Color(0xFFF5F3FF)
|
||||||
|
val Purple100 = Color(0xFFEDE9FE)
|
||||||
|
val Purple200 = Color(0xFFDDD6FE)
|
||||||
|
val Purple300 = Color(0xFFC4B5FD)
|
||||||
|
val Purple400 = Color(0xFFA78BFA)
|
||||||
|
val Purple500 = Color(0xFF8B5CF6)
|
||||||
|
val Purple600 = Color(0xFF7C3AED)
|
||||||
|
val Purple700 = Color(0xFF6D28D9)
|
||||||
|
val Purple800 = Color(0xFF5B21B6)
|
||||||
|
val Purple900 = Color(0xFF4C1D95)
|
||||||
|
val Pink50 = Color(0xFFFDF2F8)
|
||||||
|
val Pink100 = Color(0xFFFCE7F3)
|
||||||
|
val Pink200 = Color(0xFFFBCFE8)
|
||||||
|
val Pink300 = Color(0xFFF9A8D4)
|
||||||
|
val Pink400 = Color(0xFFF472B6)
|
||||||
|
val Pink500 = Color(0xFFEC4899)
|
||||||
|
val Pink600 = Color(0xFFDB2777)
|
||||||
|
val Pink700 = Color(0xFFBE185D)
|
||||||
|
val Pink800 = Color(0xFF9D174D)
|
||||||
|
val Pink900 = Color(0xFF831843)
|
||||||
90
app/src/main/java/xyz/quaver/pupil/ui/theme/Theme.kt
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package xyz.quaver.pupil.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
|
||||||
|
private val LightColors = lightColorScheme(
|
||||||
|
primary = md_theme_light_primary,
|
||||||
|
onPrimary = md_theme_light_onPrimary,
|
||||||
|
primaryContainer = md_theme_light_primaryContainer,
|
||||||
|
onPrimaryContainer = md_theme_light_onPrimaryContainer,
|
||||||
|
secondary = md_theme_light_secondary,
|
||||||
|
onSecondary = md_theme_light_onSecondary,
|
||||||
|
secondaryContainer = md_theme_light_secondaryContainer,
|
||||||
|
onSecondaryContainer = md_theme_light_onSecondaryContainer,
|
||||||
|
tertiary = md_theme_light_tertiary,
|
||||||
|
onTertiary = md_theme_light_onTertiary,
|
||||||
|
tertiaryContainer = md_theme_light_tertiaryContainer,
|
||||||
|
onTertiaryContainer = md_theme_light_onTertiaryContainer,
|
||||||
|
error = md_theme_light_error,
|
||||||
|
errorContainer = md_theme_light_errorContainer,
|
||||||
|
onError = md_theme_light_onError,
|
||||||
|
onErrorContainer = md_theme_light_onErrorContainer,
|
||||||
|
background = md_theme_light_background,
|
||||||
|
onBackground = md_theme_light_onBackground,
|
||||||
|
surface = md_theme_light_surface,
|
||||||
|
onSurface = md_theme_light_onSurface,
|
||||||
|
surfaceVariant = md_theme_light_surfaceVariant,
|
||||||
|
onSurfaceVariant = md_theme_light_onSurfaceVariant,
|
||||||
|
outline = md_theme_light_outline,
|
||||||
|
inverseOnSurface = md_theme_light_inverseOnSurface,
|
||||||
|
inverseSurface = md_theme_light_inverseSurface,
|
||||||
|
inversePrimary = md_theme_light_inversePrimary,
|
||||||
|
surfaceTint = md_theme_light_surfaceTint,
|
||||||
|
outlineVariant = md_theme_light_outlineVariant,
|
||||||
|
scrim = md_theme_light_scrim,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
private val DarkColors = darkColorScheme(
|
||||||
|
primary = md_theme_dark_primary,
|
||||||
|
onPrimary = md_theme_dark_onPrimary,
|
||||||
|
primaryContainer = md_theme_dark_primaryContainer,
|
||||||
|
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
|
||||||
|
secondary = md_theme_dark_secondary,
|
||||||
|
onSecondary = md_theme_dark_onSecondary,
|
||||||
|
secondaryContainer = md_theme_dark_secondaryContainer,
|
||||||
|
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
|
||||||
|
tertiary = md_theme_dark_tertiary,
|
||||||
|
onTertiary = md_theme_dark_onTertiary,
|
||||||
|
tertiaryContainer = md_theme_dark_tertiaryContainer,
|
||||||
|
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
|
||||||
|
error = md_theme_dark_error,
|
||||||
|
errorContainer = md_theme_dark_errorContainer,
|
||||||
|
onError = md_theme_dark_onError,
|
||||||
|
onErrorContainer = md_theme_dark_onErrorContainer,
|
||||||
|
background = md_theme_dark_background,
|
||||||
|
onBackground = md_theme_dark_onBackground,
|
||||||
|
surface = md_theme_dark_surface,
|
||||||
|
onSurface = md_theme_dark_onSurface,
|
||||||
|
surfaceVariant = md_theme_dark_surfaceVariant,
|
||||||
|
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
|
||||||
|
outline = md_theme_dark_outline,
|
||||||
|
inverseOnSurface = md_theme_dark_inverseOnSurface,
|
||||||
|
inverseSurface = md_theme_dark_inverseSurface,
|
||||||
|
inversePrimary = md_theme_dark_inversePrimary,
|
||||||
|
surfaceTint = md_theme_dark_surfaceTint,
|
||||||
|
outlineVariant = md_theme_dark_outlineVariant,
|
||||||
|
scrim = md_theme_dark_scrim,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppTheme(
|
||||||
|
useDarkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
content: @Composable() () -> Unit
|
||||||
|
) {
|
||||||
|
val colors = if (!useDarkTheme) {
|
||||||
|
LightColors
|
||||||
|
} else {
|
||||||
|
DarkColors
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colors,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package xyz.quaver.pupil.ui.viewmodel
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import xyz.quaver.pupil.networking.GalleryInfo
|
||||||
|
import xyz.quaver.pupil.networking.GallerySearchSource
|
||||||
|
import xyz.quaver.pupil.networking.SearchQuery
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
class MainViewModel : ViewModel() {
|
||||||
|
private val _uiState = MutableStateFlow(SearchState())
|
||||||
|
val searchState: StateFlow<SearchState> = _uiState
|
||||||
|
private var searchSource: GallerySearchSource = GallerySearchSource(null)
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
fun openGalleryDetails(galleryInfo: GalleryInfo) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
openedGallery = galleryInfo,
|
||||||
|
isDetailOnlyOpen = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeGalleryDetails() {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isDetailOnlyOpen = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onQueryChange(query: SearchQuery?) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
query = query,
|
||||||
|
galleryCount = null,
|
||||||
|
currentRange = IntRange.EMPTY
|
||||||
|
)
|
||||||
|
|
||||||
|
searchSource = GallerySearchSource(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadSearchResult(range: IntRange) {
|
||||||
|
Thread.dumpStack()
|
||||||
|
job?.cancel()
|
||||||
|
job = viewModelScope.launch {
|
||||||
|
val sanitizedRange = max(range.first, 0) .. min(range.last, searchState.value.galleryCount ?: Int.MAX_VALUE)
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
loading = true,
|
||||||
|
error = false,
|
||||||
|
currentRange = sanitizedRange
|
||||||
|
)
|
||||||
|
|
||||||
|
var error = false
|
||||||
|
val (galleries, galleryCount) = searchSource.load(range).getOrElse {
|
||||||
|
error = true
|
||||||
|
it.printStackTrace()
|
||||||
|
emptyList<GalleryInfo>() to 0
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
galleries = galleries,
|
||||||
|
galleryCount = galleryCount,
|
||||||
|
error = error,
|
||||||
|
loading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigateToDetail() {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SearchState(
|
||||||
|
val query: SearchQuery? = null,
|
||||||
|
val galleries: List<GalleryInfo> = emptyList(),
|
||||||
|
val loading: Boolean = false,
|
||||||
|
val error: Boolean = false,
|
||||||
|
val galleryCount: Int? = null,
|
||||||
|
val currentRange: IntRange = IntRange.EMPTY,
|
||||||
|
val openedGallery: GalleryInfo? = null,
|
||||||
|
val isDetailOnlyOpen: Boolean = false
|
||||||
|
)
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
package xyz.quaver.pupil.util
|
|
||||||
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.ContextWrapper
|
|
||||||
import android.content.Intent
|
|
||||||
import android.util.SparseArray
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.core.app.TaskStackBuilder
|
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import kotlinx.io.IOException
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonConfiguration
|
|
||||||
import kotlinx.serialization.list
|
|
||||||
import xyz.quaver.hitomi.*
|
|
||||||
import xyz.quaver.hiyobi.cookie
|
|
||||||
import xyz.quaver.hiyobi.user_agent
|
|
||||||
import xyz.quaver.pupil.R
|
|
||||||
import xyz.quaver.pupil.Pupil
|
|
||||||
import xyz.quaver.pupil.ui.ReaderActivity
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.net.URL
|
|
||||||
import java.util.*
|
|
||||||
import javax.net.ssl.HttpsURLConnection
|
|
||||||
import kotlin.collections.ArrayList
|
|
||||||
import kotlin.concurrent.schedule
|
|
||||||
|
|
||||||
class GalleryDownloader(
|
|
||||||
base: Context,
|
|
||||||
private val galleryBlock: GalleryBlock,
|
|
||||||
_notify: Boolean = false
|
|
||||||
) : ContextWrapper(base) {
|
|
||||||
|
|
||||||
private val downloads = (applicationContext as Pupil).downloads
|
|
||||||
var useHiyobi = PreferenceManager.getDefaultSharedPreferences(this).getBoolean("use_hiyobi", false)
|
|
||||||
|
|
||||||
var download: Boolean = false
|
|
||||||
set(value) {
|
|
||||||
if (value) {
|
|
||||||
field = true
|
|
||||||
notificationManager.notify(galleryBlock.id, notificationBuilder.build())
|
|
||||||
|
|
||||||
val data = getCachedGallery(this, galleryBlock.id)
|
|
||||||
val cache = File(cacheDir, "imageCache/${galleryBlock.id}")
|
|
||||||
|
|
||||||
if (File(cache, "images").exists() && !data.exists()) {
|
|
||||||
cache.copyRecursively(data, true)
|
|
||||||
cache.deleteRecursively()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader?.isActive == false && downloadJob?.isActive != true)
|
|
||||||
field = false
|
|
||||||
|
|
||||||
downloads.add(galleryBlock.id)
|
|
||||||
} else {
|
|
||||||
field = false
|
|
||||||
}
|
|
||||||
|
|
||||||
onNotifyChangedHandler?.invoke(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val reader: Deferred<Reader>?
|
|
||||||
private var downloadJob: Job? = null
|
|
||||||
|
|
||||||
private lateinit var notificationBuilder: NotificationCompat.Builder
|
|
||||||
private lateinit var notificationManager: NotificationManagerCompat
|
|
||||||
|
|
||||||
var onReaderLoadedHandler: ((Reader) -> Unit)? = null
|
|
||||||
var onProgressHandler: ((Int) -> Unit)? = null
|
|
||||||
var onDownloadedHandler: ((List<String>) -> Unit)? = null
|
|
||||||
var onErrorHandler: ((Exception) -> Unit)? = null
|
|
||||||
var onCompleteHandler: (() -> Unit)? = null
|
|
||||||
var onNotifyChangedHandler: ((Boolean) -> Unit)? = null
|
|
||||||
|
|
||||||
companion object : SparseArray<GalleryDownloader>()
|
|
||||||
|
|
||||||
init {
|
|
||||||
put(galleryBlock.id, this)
|
|
||||||
|
|
||||||
initNotification()
|
|
||||||
|
|
||||||
reader = CoroutineScope(Dispatchers.IO).async {
|
|
||||||
download = _notify
|
|
||||||
val json = Json(JsonConfiguration.Stable)
|
|
||||||
val serializer = ReaderItem.serializer().list
|
|
||||||
|
|
||||||
//Check cache
|
|
||||||
val cache = File(getCachedGallery(this@GalleryDownloader, galleryBlock.id), "reader.json")
|
|
||||||
|
|
||||||
if (cache.exists()) {
|
|
||||||
val cached = json.parse(serializer, cache.readText())
|
|
||||||
|
|
||||||
if (cached.isNotEmpty()) {
|
|
||||||
useHiyobi = when {
|
|
||||||
cached.first().url.contains("hitomi.la") -> false
|
|
||||||
else -> true
|
|
||||||
}
|
|
||||||
|
|
||||||
onReaderLoadedHandler?.invoke(cached)
|
|
||||||
|
|
||||||
return@async cached
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//Cache doesn't exist. Load from internet
|
|
||||||
val reader = when {
|
|
||||||
useHiyobi -> {
|
|
||||||
xyz.quaver.hiyobi.getReader(galleryBlock.id).let {
|
|
||||||
when {
|
|
||||||
it.isEmpty() -> {
|
|
||||||
useHiyobi = false
|
|
||||||
getReader(galleryBlock.id)
|
|
||||||
}
|
|
||||||
else -> it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
getReader(galleryBlock.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reader.isNotEmpty()) {
|
|
||||||
//Save cache
|
|
||||||
if (cache.parentFile?.exists() == false)
|
|
||||||
cache.parentFile!!.mkdirs()
|
|
||||||
|
|
||||||
cache.writeText(json.stringify(serializer, reader))
|
|
||||||
}
|
|
||||||
|
|
||||||
reader
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun webpUrlFromUrl(url: String) = url.replace("/galleries/", "/webp/") + ".webp"
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
downloadJob = CoroutineScope(Dispatchers.Default).launch {
|
|
||||||
val reader = reader!!.await()
|
|
||||||
|
|
||||||
if (reader.isEmpty())
|
|
||||||
onErrorHandler?.invoke(IOException("Couldn't retrieve Reader"))
|
|
||||||
|
|
||||||
val list = ArrayList<String>()
|
|
||||||
|
|
||||||
onReaderLoadedHandler?.invoke(reader)
|
|
||||||
|
|
||||||
notificationBuilder
|
|
||||||
.setProgress(reader.size, 0, false)
|
|
||||||
.setContentText("0/${reader.size}")
|
|
||||||
|
|
||||||
reader.chunked(4).forEachIndexed { chunkIndex, chunked ->
|
|
||||||
chunked.mapIndexed { i, it ->
|
|
||||||
val index = chunkIndex*4+i
|
|
||||||
|
|
||||||
onProgressHandler?.invoke(index)
|
|
||||||
|
|
||||||
notificationBuilder
|
|
||||||
.setProgress(reader.size, index, false)
|
|
||||||
.setContentText("$index/${reader.size}")
|
|
||||||
|
|
||||||
if (download)
|
|
||||||
notificationManager.notify(galleryBlock.id, notificationBuilder.build())
|
|
||||||
|
|
||||||
async(Dispatchers.IO) {
|
|
||||||
val url = if (it.galleryInfo?.haswebp == 1) webpUrlFromUrl(it.url) else it.url
|
|
||||||
|
|
||||||
val name = "$index".padStart(4, '0')
|
|
||||||
val ext = url.split('.').last()
|
|
||||||
|
|
||||||
val cache = File(getCachedGallery(this@GalleryDownloader, galleryBlock.id), "images/$name.$ext")
|
|
||||||
|
|
||||||
if (!cache.exists())
|
|
||||||
try {
|
|
||||||
with(URL(url).openConnection() as HttpsURLConnection) {
|
|
||||||
if (useHiyobi) {
|
|
||||||
setRequestProperty("User-Agent", user_agent)
|
|
||||||
setRequestProperty("Cookie", cookie)
|
|
||||||
} else
|
|
||||||
setRequestProperty("Referer", getReferer(galleryBlock.id))
|
|
||||||
|
|
||||||
if (cache.parentFile?.exists() == false)
|
|
||||||
cache.parentFile!!.mkdirs()
|
|
||||||
|
|
||||||
inputStream.copyTo(FileOutputStream(cache))
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
cache.delete()
|
|
||||||
|
|
||||||
onErrorHandler?.invoke(e)
|
|
||||||
|
|
||||||
notificationBuilder
|
|
||||||
.setContentTitle(galleryBlock.title)
|
|
||||||
.setContentText(getString(R.string.reader_notification_error))
|
|
||||||
.setProgress(0, 0, false)
|
|
||||||
|
|
||||||
notificationManager.notify(galleryBlock.id, notificationBuilder.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.absolutePath
|
|
||||||
}
|
|
||||||
}.forEach {
|
|
||||||
list.add(it.await())
|
|
||||||
onDownloadedHandler?.invoke(list)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Timer(false).schedule(1000) {
|
|
||||||
notificationBuilder
|
|
||||||
.setContentTitle(galleryBlock.title)
|
|
||||||
.setContentText(getString(R.string.reader_notification_complete))
|
|
||||||
.setProgress(0, 0, false)
|
|
||||||
|
|
||||||
if (download) {
|
|
||||||
File(cacheDir, "imageCache/${galleryBlock.id}").let {
|
|
||||||
if (it.exists()) {
|
|
||||||
val target = File(getDownloadDirectory(this@GalleryDownloader), galleryBlock.id.toString())
|
|
||||||
|
|
||||||
if (!target.exists())
|
|
||||||
target.mkdirs()
|
|
||||||
|
|
||||||
it.copyRecursively(target, true)
|
|
||||||
it.deleteRecursively()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notificationManager.notify(galleryBlock.id, notificationBuilder.build())
|
|
||||||
|
|
||||||
download = false
|
|
||||||
}
|
|
||||||
|
|
||||||
onCompleteHandler?.invoke()
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(galleryBlock.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancel() {
|
|
||||||
downloadJob?.cancel()
|
|
||||||
|
|
||||||
remove(galleryBlock.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun cancelAndJoin() {
|
|
||||||
downloadJob?.cancelAndJoin()
|
|
||||||
|
|
||||||
remove(galleryBlock.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun invokeOnReaderLoaded() {
|
|
||||||
CoroutineScope(Dispatchers.Default).launch {
|
|
||||||
onReaderLoadedHandler?.invoke(reader?.await() ?: return@launch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearNotification() {
|
|
||||||
notificationManager.cancel(galleryBlock.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun invokeOnNotifyChanged() {
|
|
||||||
onNotifyChangedHandler?.invoke(download)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initNotification() {
|
|
||||||
val intent = Intent(this, ReaderActivity::class.java).apply {
|
|
||||||
putExtra("galleryblock", Json(JsonConfiguration.Stable).stringify(GalleryBlock.serializer(), galleryBlock))
|
|
||||||
}
|
|
||||||
val pendingIntent = TaskStackBuilder.create(this).run {
|
|
||||||
addNextIntentWithParentStack(intent)
|
|
||||||
getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT)
|
|
||||||
}
|
|
||||||
|
|
||||||
notificationBuilder = NotificationCompat.Builder(this, "download").apply {
|
|
||||||
setContentTitle(galleryBlock.title)
|
|
||||||
setContentText(getString(R.string.reader_notification_text))
|
|
||||||
setSmallIcon(R.drawable.ic_download)
|
|
||||||
setContentIntent(pendingIntent)
|
|
||||||
setProgress(0, 0, true)
|
|
||||||
priority = NotificationCompat.PRIORITY_LOW
|
|
||||||
}
|
|
||||||
notificationManager = NotificationManagerCompat.from(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
package xyz.quaver.pupil.util;
|
|
||||||
|
|
||||||
import android.view.View;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
|
||||||
import xyz.quaver.pupil.R;
|
|
||||||
|
|
||||||
/*
|
|
||||||
Source: http://www.littlerobots.nl/blog/Handle-Android-RecyclerView-Clicks/
|
|
||||||
USAGE:
|
|
||||||
|
|
||||||
ItemClickSupport.addTo(mRecyclerView).setOnItemClickListener(new ItemClickSupport.OnItemClickListener() {
|
|
||||||
@Override
|
|
||||||
public void onItemClicked(RecyclerView recyclerView, int position, View v) {
|
|
||||||
// do it
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
*/
|
|
||||||
public class ItemClickSupport {
|
|
||||||
private final RecyclerView mRecyclerView;
|
|
||||||
private OnItemClickListener mOnItemClickListener;
|
|
||||||
private OnItemLongClickListener mOnItemLongClickListener;
|
|
||||||
private View.OnClickListener mOnClickListener = new View.OnClickListener() {
|
|
||||||
@Override
|
|
||||||
public void onClick(View v) {
|
|
||||||
if (mOnItemClickListener != null) {
|
|
||||||
RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(v);
|
|
||||||
mOnItemClickListener.onItemClicked(mRecyclerView, holder.getAdapterPosition(), v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
private View.OnLongClickListener mOnLongClickListener = new View.OnLongClickListener() {
|
|
||||||
@Override
|
|
||||||
public boolean onLongClick(View v) {
|
|
||||||
if (mOnItemLongClickListener != null) {
|
|
||||||
RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(v);
|
|
||||||
return mOnItemLongClickListener.onItemLongClicked(mRecyclerView, holder.getAdapterPosition(), v);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
private RecyclerView.OnChildAttachStateChangeListener mAttachListener
|
|
||||||
= new RecyclerView.OnChildAttachStateChangeListener() {
|
|
||||||
@Override
|
|
||||||
public void onChildViewAttachedToWindow(@NonNull View view) {
|
|
||||||
if (mOnItemClickListener != null) {
|
|
||||||
view.setOnClickListener(mOnClickListener);
|
|
||||||
}
|
|
||||||
if (mOnItemLongClickListener != null) {
|
|
||||||
view.setOnLongClickListener(mOnLongClickListener);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onChildViewDetachedFromWindow(@NonNull View view) {
|
|
||||||
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private ItemClickSupport(RecyclerView recyclerView) {
|
|
||||||
mRecyclerView = recyclerView;
|
|
||||||
mRecyclerView.setTag(R.id.item_click_support, this);
|
|
||||||
mRecyclerView.addOnChildAttachStateChangeListener(mAttachListener);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ItemClickSupport addTo(RecyclerView view) {
|
|
||||||
ItemClickSupport support = (ItemClickSupport) view.getTag(R.id.item_click_support);
|
|
||||||
if (support == null) {
|
|
||||||
support = new ItemClickSupport(view);
|
|
||||||
}
|
|
||||||
return support;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ItemClickSupport removeFrom(RecyclerView view) {
|
|
||||||
ItemClickSupport support = (ItemClickSupport) view.getTag(R.id.item_click_support);
|
|
||||||
if (support != null) {
|
|
||||||
support.detach(view);
|
|
||||||
}
|
|
||||||
return support;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ItemClickSupport setOnItemClickListener(OnItemClickListener listener) {
|
|
||||||
mOnItemClickListener = listener;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public ItemClickSupport setOnItemLongClickListener(OnItemLongClickListener listener) {
|
|
||||||
mOnItemLongClickListener = listener;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void detach(RecyclerView view) {
|
|
||||||
view.removeOnChildAttachStateChangeListener(mAttachListener);
|
|
||||||
view.setTag(R.id.item_click_support, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface OnItemClickListener {
|
|
||||||
|
|
||||||
void onItemClicked(RecyclerView recyclerView, int position, View v);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface OnItemLongClickListener {
|
|
||||||
|
|
||||||
boolean onItemLongClicked(RecyclerView recyclerView, int position, View v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package xyz.quaver.pupil.util
|
|
||||||
|
|
||||||
import android.graphics.Paint
|
|
||||||
import android.text.style.LineHeightSpan
|
|
||||||
|
|
||||||
class SetLineOverlap(private val overlap: Boolean) : LineHeightSpan {
|
|
||||||
companion object {
|
|
||||||
private var originalBottom = 15
|
|
||||||
private var originalDescent = 13
|
|
||||||
private var overlapSaved = false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun chooseHeight(
|
|
||||||
text: CharSequence?,
|
|
||||||
start: Int,
|
|
||||||
end: Int,
|
|
||||||
spanstartv: Int,
|
|
||||||
lineHeight: Int,
|
|
||||||
fm: Paint.FontMetricsInt?
|
|
||||||
) {
|
|
||||||
fm ?: return
|
|
||||||
|
|
||||||
if (overlap) {
|
|
||||||
if (overlapSaved) {
|
|
||||||
originalBottom = fm.bottom
|
|
||||||
originalDescent = fm.descent
|
|
||||||
overlapSaved = true
|
|
||||||
}
|
|
||||||
fm.bottom += fm.top
|
|
||||||
fm.descent += fm.top
|
|
||||||
} else {
|
|
||||||
fm.bottom = originalBottom
|
|
||||||
fm.descent = originalDescent
|
|
||||||
overlapSaved = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package xyz.quaver.pupil.util
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Environment
|
|
||||||
import android.provider.MediaStore
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
fun getCachedGallery(context: Context, galleryID: Int): File {
|
|
||||||
return File(getDownloadDirectory(context), galleryID.toString()).let {
|
|
||||||
when {
|
|
||||||
it.exists() -> it
|
|
||||||
else -> File(context.cacheDir, "imageCache/$galleryID")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDownloadDirectory(context: Context): File? {
|
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
|
||||||
context.getExternalFilesDir("Pupil")
|
|
||||||
else
|
|
||||||
File(Environment.getExternalStorageDirectory(), "Pupil")
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package xyz.quaver.pupil.util
|
|
||||||
|
|
||||||
import kotlinx.serialization.ImplicitReflectionSerializer
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonConfiguration
|
|
||||||
import kotlinx.serialization.parseList
|
|
||||||
import kotlinx.serialization.stringify
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class Histories(private val file: File) : ArrayList<Int>() {
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (!file.exists())
|
|
||||||
file.parentFile.mkdirs()
|
|
||||||
|
|
||||||
try {
|
|
||||||
load()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseExperimental(ImplicitReflectionSerializer::class)
|
|
||||||
fun load() : Histories {
|
|
||||||
return apply {
|
|
||||||
super.clear()
|
|
||||||
addAll(
|
|
||||||
Json(JsonConfiguration.Stable).parseList(
|
|
||||||
file.bufferedReader().use { it.readText() }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseExperimental(ImplicitReflectionSerializer::class)
|
|
||||||
fun save() {
|
|
||||||
file.writeText(Json(JsonConfiguration.Stable).stringify(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun add(element: Int): Boolean {
|
|
||||||
load()
|
|
||||||
|
|
||||||
if (contains(element))
|
|
||||||
super.remove(element)
|
|
||||||
|
|
||||||
super.add(0, element)
|
|
||||||
|
|
||||||
save()
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun remove(element: Int): Boolean {
|
|
||||||
load()
|
|
||||||
val retval = super.remove(element)
|
|
||||||
save()
|
|
||||||
|
|
||||||
return retval
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun clear() {
|
|
||||||
super.clear()
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
package xyz.quaver.pupil.util
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.ContextWrapper
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import kotlinx.serialization.*
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonConfiguration
|
|
||||||
import java.io.File
|
|
||||||
import java.security.MessageDigest
|
|
||||||
|
|
||||||
fun hash(password: String): String {
|
|
||||||
val bytes = password.toByteArray()
|
|
||||||
val md = MessageDigest.getInstance("SHA-256")
|
|
||||||
|
|
||||||
return md.digest(bytes).fold("") { str, it -> str + "%02x".format(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ret1: SHA-256 Hash
|
|
||||||
// Ret2: Hash salt
|
|
||||||
fun hashWithSalt(password: String): Pair<String, String> {
|
|
||||||
val salt = (0 until 12).map { source.random() }.joinToString()
|
|
||||||
|
|
||||||
return Pair(hash(password+salt), salt)
|
|
||||||
}
|
|
||||||
|
|
||||||
val source = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Lock(val type: Type, val hash: String, val salt: String) {
|
|
||||||
|
|
||||||
enum class Type {
|
|
||||||
PATTERN,
|
|
||||||
PIN,
|
|
||||||
PASSWORD
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun generate(type: Type, password: String): Lock {
|
|
||||||
val (hash, salt) = hashWithSalt(password)
|
|
||||||
return Lock(type, hash, salt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun match(password: String): Boolean {
|
|
||||||
return hash(password+salt) == hash
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class LockManager(base: Context): ContextWrapper(base) {
|
|
||||||
|
|
||||||
var locks: ArrayList<Lock>? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
load()
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseExperimental(ImplicitReflectionSerializer::class)
|
|
||||||
private fun load() {
|
|
||||||
val lock = File(ContextCompat.getDataDir(this), "lock.json")
|
|
||||||
|
|
||||||
if (!lock.exists()) {
|
|
||||||
lock.createNewFile()
|
|
||||||
lock.writeText("[]")
|
|
||||||
}
|
|
||||||
|
|
||||||
locks = ArrayList(Json(JsonConfiguration.Stable).parseList(lock.readText()))
|
|
||||||
}
|
|
||||||
|
|
||||||
@UseExperimental(ImplicitReflectionSerializer::class)
|
|
||||||
private fun save() {
|
|
||||||
val lock = File(ContextCompat.getDataDir(this), "lock.json")
|
|
||||||
|
|
||||||
if (!lock.exists())
|
|
||||||
lock.createNewFile()
|
|
||||||
|
|
||||||
lock.writeText(Json(JsonConfiguration.Stable).stringify(locks?.toList() ?: listOf()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun add(lock: Lock) {
|
|
||||||
remove(lock.type)
|
|
||||||
locks?.add(lock)
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun remove(type: Lock.Type) {
|
|
||||||
locks?.removeAll { it.type == type }
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun check(password: String): Boolean? {
|
|
||||||
return locks?.any {
|
|
||||||
it.match(password)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun empty(): Boolean {
|
|
||||||
return locks.isNullOrEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun contains(type: Lock.Type): Boolean {
|
|
||||||
return locks?.any { it.type == type } ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package xyz.quaver.pupil.util
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import kotlinx.serialization.json.*
|
|
||||||
import java.net.URL
|
|
||||||
|
|
||||||
fun getReleases(url: String) : JsonArray {
|
|
||||||
return try {
|
|
||||||
URL(url).readText().let {
|
|
||||||
Json(JsonConfiguration.Stable).parse(JsonArray.serializer(), it)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
JsonArray(emptyList())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun checkUpdate(url: String, currentVersion: String) : JsonObject? {
|
|
||||||
val releases = getReleases(url)
|
|
||||||
|
|
||||||
if (releases.isEmpty())
|
|
||||||
return null
|
|
||||||
|
|
||||||
val latestVersion = releases[0].jsonObject["tag_name"]?.content
|
|
||||||
|
|
||||||
return when {
|
|
||||||
currentVersion.split('-').size == 1 -> {
|
|
||||||
when {
|
|
||||||
currentVersion != latestVersion -> releases[0].jsonObject
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
when {
|
|
||||||
(currentVersion.split('-')[0] == latestVersion) -> releases[0].jsonObject
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<animated-vector
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
tools:ignore="NewApi">
|
|
||||||
<aapt:attr name="android:drawable">
|
|
||||||
<vector
|
|
||||||
android:name="vector"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<path
|
|
||||||
android:name="path"
|
|
||||||
android:pathData="M 19 9 L 15 9 L 15 3 L 9 3 L 9 9 L 5 9 L 12 16 L 19 9 Z"
|
|
||||||
android:fillColor="#fff"
|
|
||||||
android:strokeWidth="1"/>
|
|
||||||
<path
|
|
||||||
android:name="path_2"
|
|
||||||
android:pathData="M 5 19 L 19 19"
|
|
||||||
android:fillColor="#fff"
|
|
||||||
android:strokeColor="#fff"
|
|
||||||
android:strokeWidth="2"
|
|
||||||
android:strokeLineCap="butt"/>
|
|
||||||
</vector>
|
|
||||||
</aapt:attr>
|
|
||||||
<target android:name="path_2">
|
|
||||||
<aapt:attr name="android:animation">
|
|
||||||
<set>
|
|
||||||
<objectAnimator
|
|
||||||
android:propertyName="trimPathEnd"
|
|
||||||
android:duration="500"
|
|
||||||
android:valueFrom="0"
|
|
||||||
android:valueTo="0.8"
|
|
||||||
android:valueType="floatType"
|
|
||||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
|
||||||
<objectAnimator
|
|
||||||
android:propertyName="trimPathStart"
|
|
||||||
android:startOffset="500"
|
|
||||||
android:duration="500"
|
|
||||||
android:valueFrom="0"
|
|
||||||
android:valueTo="0.8"
|
|
||||||
android:valueType="floatType"
|
|
||||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
|
||||||
<objectAnimator
|
|
||||||
android:propertyName="trimPathOffset"
|
|
||||||
android:duration="1000"
|
|
||||||
android:valueFrom="0"
|
|
||||||
android:valueTo="0.2"
|
|
||||||
android:valueType="floatType"
|
|
||||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
|
||||||
</set>
|
|
||||||
</aapt:attr>
|
|
||||||
</target>
|
|
||||||
</animated-vector>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24"
|
|
||||||
android:tint="#333333">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M20,4L4,4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,6c0,-1.1 -0.9,-2 -2,-2zM20,8l-8,5 -8,-5L4,6l8,5 8,-5v2z"/>
|
|
||||||
</vector>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24"
|
|
||||||
android:tint="#333333">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M16.59,8.59L12,13.17 7.41,8.59 6,10l6,6 6,-6z"/>
|
|
||||||
</vector>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24"
|
|
||||||
android:tint="#FFFFFF">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M7,14L5,14v5h5v-2L7,17v-3zM5,10h2L7,7h3L10,5L5,5v5zM17,17h-3v2h5v-5h-2v3zM14,5v2h3v3h2L19,5h-5z"/>
|
|
||||||
</vector>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24"
|
|
||||||
android:tint="#333333">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,19h-2v-2h2v2zM15.07,11.25l-0.9,0.92C13.45,12.9 13,13.5 13,15h-2v-0.5c0,-1.1 0.45,-2.1 1.17,-2.83l1.24,-1.26c0.37,-0.36 0.59,-0.86 0.59,-1.41 0,-1.1 -0.9,-2 -2,-2s-2,0.9 -2,2L8,9c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,0.88 -0.36,1.68 -0.93,2.25z"/>
|
|
||||||
</vector>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24"
|
|
||||||
android:tint="#333333">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08L13.5,8L12,8z"/>
|
|
||||||
</vector>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24"
|
|
||||||
android:tint="#333333">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M10,20v-6h4v6h5v-8h3L12,3 2,12h3v8z"/>
|
|
||||||
</vector>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24"
|
|
||||||
android:tint="#333333">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M21,5v6.59l-3,-3.01 -4,4.01 -4,-4 -4,4 -3,-3.01L3,5c0,-1.1 0.9,-2 2,-2h14c1.1,0 2,0.9 2,2zM18,11.42l3,3.01L21,19c0,1.1 -0.9,2 -2,2L5,21c-1.1,0 -2,-0.9 -2,-2v-6.58l3,2.99 4,-4 4,4 4,-3.99z"/>
|
|
||||||
</vector>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24"
|
|
||||||
android:tint="#FFFFFF">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/>
|
|
||||||
</vector>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24"
|
|
||||||
android:tint="#FFFFFF">
|
|
||||||
<path
|
|
||||||
android:fillColor="#FF000000"
|
|
||||||
android:pathData="M19.1,12.9a2.8,2.8 0,0 0,0.1 -0.9,2.8 2.8,0 0,0 -0.1,-0.9l2.1,-1.6a0.7,0.7 0,0 0,0.1 -0.6L19.4,5.5a0.7,0.7 0,0 0,-0.6 -0.2l-2.4,1a6.5,6.5 0,0 0,-1.6 -0.9l-0.4,-2.6a0.5,0.5 0,0 0,-0.5 -0.4H10.1a0.5,0.5 0,0 0,-0.5 0.4L9.3,5.4a5.6,5.6 0,0 0,-1.7 0.9l-2.4,-1a0.4,0.4 0,0 0,-0.5 0.2l-2,3.4c-0.1,0.2 0,0.4 0.2,0.6l2,1.6a2.8,2.8 0,0 0,-0.1 0.9,2.8 2.8,0 0,0 0.1,0.9L2.8,14.5a0.7,0.7 0,0 0,-0.1 0.6l1.9,3.4a0.7,0.7 0,0 0,0.6 0.2l2.4,-1a6.5,6.5 0,0 0,1.6 0.9l0.4,2.6a0.5,0.5 0,0 0,0.5 0.4h3.8a0.5,0.5 0,0 0,0.5 -0.4l0.3,-2.6a5.6,5.6 0,0 0,1.7 -0.9l2.4,1a0.4,0.4 0,0 0,0.5 -0.2l2,-3.4c0.1,-0.2 0,-0.4 -0.2,-0.6ZM12,15.6A3.6,3.6 0,1 1,15.6 12,3.6 3.6,0 0,1 12,15.6Z"/>
|
|
||||||
</vector>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 620 B |
|
Before Width: | Height: | Size: 975 B |
|
Before Width: | Height: | Size: 325 B |
|
Before Width: | Height: | Size: 197 B |
|
Before Width: | Height: | Size: 145 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 571 B |
|
Before Width: | Height: | Size: 585 B |
|
Before Width: | Height: | Size: 279 B |
|
Before Width: | Height: | Size: 361 B |
|
Before Width: | Height: | Size: 470 B |
|
Before Width: | Height: | Size: 469 B |
|
Before Width: | Height: | Size: 965 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 793 B |
|
Before Width: | Height: | Size: 802 B |
|
Before Width: | Height: | Size: 495 B |
|
Before Width: | Height: | Size: 639 B |
|
Before Width: | Height: | Size: 733 B |
|
Before Width: | Height: | Size: 817 B |
|
Before Width: | Height: | Size: 670 B |
|
Before Width: | Height: | Size: 934 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 979 B |
|
Before Width: | Height: | Size: 636 B |