Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deb900f8f8 | ||
|
|
e6f6ed75f1 | ||
|
|
6226c3b0c8 | ||
|
|
1a525d6e5d | ||
|
|
bc213fe3c9 | ||
|
|
731666936c | ||
|
|
67ec7e92b5 | ||
|
|
40bfcb07b6 | ||
|
|
e5f09a7aef | ||
|
|
341bdb0eda | ||
|
|
27d68f2774 | ||
|
|
d259cd8eb5 | ||
|
|
c9e5ded43a | ||
|
|
a8e03e22bf | ||
|
|
7094016e7e | ||
|
|
1bfa1f3981 | ||
|
|
655745e049 | ||
|
|
f83a22b310 | ||
|
|
025c24a9d9 | ||
|
|
c8c3e83e7b | ||
|
|
7d8327af18 | ||
|
|
58bab2f538 | ||
|
|
cf93eda6c1 | ||
|
|
a3b7af0f79 | ||
|
|
91bf5e5555 | ||
|
|
61a72b330d | ||
|
|
6a1164363c | ||
|
|
df9a82482c | ||
|
|
778d98c7f2 | ||
|
|
cae759a09e | ||
|
|
52126b2248 | ||
|
|
5782521824 | ||
|
|
3522dc529d | ||
|
|
8602e68b60 | ||
|
|
fb4514d70e | ||
|
|
35d86bff11 | ||
|
|
193730e16c | ||
|
|
ed32186984 | ||
|
|
eb449740eb | ||
|
|
327fd43dd6 | ||
|
|
bc2345f415 | ||
|
|
fd1bdc79f8 | ||
|
|
22a2419185 | ||
|
|
de921bf786 | ||
|
|
3cf6b22d95 | ||
|
|
82ce45b8e4 | ||
|
|
dfe95a646e | ||
|
|
7b0b206905 | ||
|
|
1b1984064c | ||
|
|
7e03e2571f | ||
|
|
23f0b32634 | ||
|
|
f844062980 | ||
|
|
84f982b20e | ||
|
|
c6739487a4 | ||
|
|
be0f46371e | ||
|
|
85351d5b1c | ||
|
|
23366e4536 | ||
|
|
0d1f69d36e | ||
|
|
d77db45f37 | ||
|
|
20c5b6c4a5 | ||
|
|
62f512e319 | ||
|
|
e740ddb7cc | ||
|
|
68924d6f35 | ||
|
|
94db512518 | ||
|
|
f39e9889f3 | ||
|
|
b0ffbcd07d | ||
|
|
19412cc963 | ||
|
|
45c7dbb3f7 | ||
|
|
3b0d0e55dc | ||
|
|
ae21ebe80d | ||
|
|
cca14765ce | ||
|
|
725718d18c | ||
|
|
61b000dc48 | ||
|
|
cf850071e5 | ||
|
|
fe473e70a3 | ||
|
|
ce6540a6f3 | ||
|
|
ac3e60053a | ||
|
|
205ae8d9c7 | ||
|
|
772d368caa | ||
|
|
d2fa330491 | ||
|
|
f85856a6d9 | ||
|
|
4ed5988aa8 | ||
|
|
ac4bf7f4dd | ||
|
|
7b5cfa97d7 | ||
|
|
93292c3d5a | ||
|
|
62ebb43e6f | ||
|
|
5dabebf579 | ||
|
|
96728a3834 | ||
|
|
10aa416e94 | ||
|
|
e283584999 | ||
|
|
edea0c6ab1 | ||
|
|
1cb3eb6472 | ||
|
|
ddfa62f4fb | ||
|
|
88a119d66b | ||
|
|
de16ff52af | ||
|
|
8dc3a034c1 | ||
|
|
f9b2581464 | ||
|
|
2f2d2e694e | ||
|
|
25382acf0e | ||
|
|
d4672ae8bd | ||
|
|
4c87b644c0 | ||
|
|
f546f98227 | ||
|
|
e878df3d60 | ||
|
|
e49a2c2cbc | ||
|
|
f371c435fd | ||
|
|
19e7d65006 | ||
|
|
60506b4725 | ||
|
|
9cfa4d8ac4 | ||
|
|
8195a15a01 | ||
|
|
721b74fedc | ||
|
|
62ea7cd456 | ||
|
|
b60adaf87d | ||
|
|
0852f197be | ||
|
|
ef6359274d | ||
|
|
b31a3755bb | ||
|
|
c87f6a4239 | ||
|
|
c279765386 | ||
|
|
ac3dd45ee6 | ||
|
|
6db7880178 | ||
|
|
0c29a6626d | ||
|
|
183e1606c5 | ||
|
|
d5a4d7f91a | ||
|
|
5a491429db | ||
|
|
6265c50caf | ||
|
|
4b4f00ad8e | ||
|
|
ed071a0fff | ||
|
|
db393d4534 | ||
|
|
9dcf26b5a4 | ||
|
|
43402f45d8 | ||
|
|
76b5395fd2 | ||
|
|
f9fa30a58d | ||
|
|
b7660a93b5 | ||
|
|
5858466318 | ||
|
|
4d5ce37982 | ||
|
|
d84e6c8e7c | ||
|
|
9768ffa8ff | ||
|
|
5cb4af40cd | ||
|
|
b43d238dd1 | ||
|
|
3c8a23e91e | ||
|
|
63d74ae065 | ||
|
|
f0d4395da6 | ||
|
|
41fe784641 | ||
|
|
7c07e90d0e | ||
|
|
6731453c41 | ||
|
|
932b474763 | ||
|
|
1600b7a200 | ||
|
|
0d23225ec7 | ||
|
|
5214355339 | ||
|
|
81b96cf21d | ||
|
|
492eff8cde | ||
|
|
49c5029256 | ||
|
|
fcd85dc8e1 | ||
|
|
bc3a1266f4 | ||
|
|
bc2365b08b | ||
|
|
9c32d6721b | ||
|
|
1394bc74c4 | ||
|
|
cbf0707aa6 | ||
|
|
67e621160f | ||
|
|
e06a8ba236 | ||
|
|
5bd37a837c | ||
|
|
828fb74240 | ||
|
|
8241a6d901 | ||
|
|
edadcc6744 | ||
|
|
44438da292 | ||
|
|
591c68a6e4 | ||
|
|
892d68581d | ||
|
|
a4b39ce00e | ||
|
|
9ce0d3a8b4 | ||
|
|
23b07c4bad | ||
|
|
f3e28d2e58 | ||
|
|
7a5da2af9f | ||
|
|
4c28da30ae | ||
|
|
4961af8a20 | ||
|
|
1e396bc7ff | ||
|
|
52deb7ca5f | ||
|
|
8891667c92 | ||
|
|
e7e51f38d4 | ||
|
|
3de6a94ea0 | ||
|
|
ef0db7d5ae | ||
|
|
45701f414b | ||
|
|
14007c6e8f | ||
|
|
6ca500066f | ||
|
|
69f63cced4 | ||
|
|
1e0cc062d5 | ||
|
|
f6e9b3a83f | ||
|
|
e0eb748318 | ||
|
|
d65b8b6799 | ||
|
|
d41a1f444f | ||
|
|
786f674044 | ||
|
|
432a438815 | ||
|
|
bae671ad23 | ||
|
|
45b6f3c4d5 | ||
|
|
79a2a060c7 | ||
|
|
80736d6538 | ||
|
|
cb77a3ae85 | ||
|
|
80ab034493 | ||
|
|
3bb5dfffbe | ||
|
|
d9ab751dbd | ||
|
|
e3627ca89d | ||
|
|
543edf36e8 | ||
|
|
fb5721beda | ||
|
|
6e67a3de5f | ||
|
|
6d944967f0 | ||
|
|
2628bfc2cd | ||
|
|
d77fa13903 | ||
|
|
f71aa07949 | ||
|
|
a41b465648 | ||
|
|
a7038600b3 | ||
|
|
3143556c63 | ||
|
|
5e2d9a2355 | ||
|
|
b4adfede90 | ||
|
|
ed85ba905d | ||
|
|
c29e4065d6 | ||
|
|
bcf4f02a01 | ||
|
|
72b9409383 | ||
|
|
2c762428af | ||
|
|
58e427c13a | ||
|
|
974f03ee89 | ||
|
|
f5e6b25ec6 | ||
|
|
be5f073acf | ||
|
|
ca6b941cbb | ||
|
|
1e5f830652 | ||
|
|
e72a5fda5c | ||
|
|
5c2fe6e630 | ||
|
|
5907c2a09a | ||
|
|
e3b381c665 | ||
|
|
f1d30e59b0 | ||
|
|
7669a85f43 | ||
|
|
c05db765c8 | ||
|
|
4c8e30bcc9 | ||
|
|
d32819b1ff | ||
|
|
a2d046bd0b | ||
|
|
3ad4ff1c63 | ||
|
|
1defffa504 | ||
|
|
0d78cb4112 | ||
|
|
7c59130af9 | ||
|
|
4137d73539 | ||
|
|
0f8a649e3b | ||
|
|
7e7a4bec83 | ||
|
|
b442b14cc1 | ||
|
|
97886f6ece | ||
|
|
7b9245e62c | ||
|
|
99aa62d0e4 | ||
|
|
c3cf8a8a39 | ||
|
|
340655f18a | ||
|
|
10fa84b09e | ||
|
|
f21a03b20b | ||
|
|
56394fc275 | ||
|
|
9fd2d9f17b | ||
|
|
0c0506b6d7 | ||
|
|
f94741144c | ||
|
|
29c0369d43 | ||
|
|
e700987d14 | ||
|
|
c5ec6cd680 | ||
|
|
e48a6d41d5 | ||
|
|
5870af4017 | ||
|
|
cb4c85b41c | ||
|
|
cd6a04c5fd | ||
|
|
401214794d | ||
|
|
fdd2f7008c | ||
|
|
18ef9c63b9 | ||
|
|
d343f3db2b | ||
|
|
42cc852a20 | ||
|
|
44be6eacbb | ||
|
|
778be597dd | ||
|
|
255439e2b4 | ||
|
|
63c3e26f5c | ||
|
|
d3c3e3ca08 | ||
|
|
8d6aba9865 | ||
|
|
5e91e20c51 | ||
|
|
84c9dd3762 | ||
|
|
24ef11b315 | ||
|
|
bdf218cd85 | ||
|
|
b3ff40b12b | ||
|
|
bad5bf5457 | ||
|
|
cf3ca27966 | ||
|
|
127a0674a2 | ||
|
|
b1a18c5dec | ||
|
|
0a39f56775 | ||
|
|
5daeb8d580 | ||
|
|
415ddae9f7 | ||
|
|
4c95680f3b | ||
|
|
b6cb9a2077 | ||
|
|
13eefbba2a | ||
|
|
6548f7d654 | ||
|
|
1e4217598f | ||
|
|
0359860b82 | ||
|
|
37f42dc107 | ||
|
|
7eb4b45ab1 | ||
|
|
3706fdd284 | ||
|
|
f1fa96f8ca | ||
|
|
36cadcdd78 | ||
|
|
cf3ac329f6 | ||
|
|
0581cedfb0 | ||
|
|
0fc5adc197 | ||
|
|
0680585b77 | ||
|
|
2a3514e3b5 | ||
|
|
fd8b65be72 | ||
|
|
50870b5fb5 | ||
|
|
7c5d935801 | ||
|
|
2862ada772 | ||
|
|
65d1457386 | ||
|
|
0f63611c2d | ||
|
|
7767f1c6d7 | ||
|
|
f351b60073 | ||
|
|
752dee8ed6 | ||
|
|
0573e8cb2d | ||
|
|
75fd41a0d8 | ||
|
|
59f8577d1f | ||
|
|
85d4b50d28 | ||
|
|
c3499d434b | ||
|
|
701e0d7fcc | ||
|
|
9a5a417418 | ||
|
|
1221992d26 | ||
|
|
144cdb5e50 | ||
|
|
8133b2c091 | ||
|
|
869cc650e3 | ||
|
|
7a3262338d | ||
|
|
85ff15e8ca | ||
|
|
3d34c4f466 | ||
|
|
6da6364a55 | ||
|
|
26b04cce16 | ||
|
|
9ab2400e1f | ||
|
|
c3b30cc86c | ||
|
|
2a97383e7d | ||
|
|
dd20885270 | ||
|
|
fe752f839a | ||
|
|
be9a888816 | ||
|
|
648e7b31cf | ||
|
|
21b520f786 | ||
|
|
8bbcabc8f6 | ||
|
|
0e4e344a2f | ||
|
|
7b25b6aa85 | ||
|
|
5343168b8a | ||
|
|
9b39fcf1fc | ||
|
|
8ce7ac96d0 | ||
|
|
b5c57500b1 | ||
|
|
c089e5ae5a | ||
|
|
d8a76cf1e7 | ||
|
|
92fce55feb | ||
|
|
60a430baa4 | ||
|
|
b0c8de888c | ||
|
|
73ab2dd1d3 | ||
|
|
7c4f843f42 | ||
|
|
123ec904e3 | ||
|
|
80c9211d37 | ||
|
|
b030694f63 | ||
|
|
e40e196354 | ||
|
|
b7abef3f8f | ||
|
|
5bde6cd9cb | ||
|
|
cb8c90ace9 | ||
|
|
9c2c238e1e | ||
|
|
21a7baa309 | ||
|
|
a9d75148fd | ||
|
|
3209b5b1fe | ||
|
|
c749a97ded | ||
|
|
f1ab468ceb | ||
|
|
ebd0e5cb5d | ||
|
|
19bf81ac0e | ||
|
|
569d5af8fb | ||
|
|
dc754dabb3 | ||
|
|
ed42532416 | ||
|
|
211f39da52 | ||
|
|
fefac25781 | ||
|
|
e0c9c88ca9 | ||
|
|
ea7f7b6306 | ||
|
|
8ceca6c5ed | ||
|
|
e9ff85ad44 | ||
|
|
b8e6d4d3fe | ||
|
|
e7fdfcab69 | ||
|
|
ad8c830b17 | ||
|
|
25d2d4ddb7 | ||
|
|
22d1755fd2 | ||
|
|
d397680d33 | ||
|
|
40cd7758dc | ||
|
|
2dc8eae861 | ||
|
|
2d23ceee83 | ||
|
|
cccc6fd64b | ||
|
|
aced95c542 | ||
|
|
90edeefd6e | ||
|
|
a5b90851c8 | ||
|
|
7d0c6972ef | ||
|
|
18cb5dc07c | ||
|
|
5343e13215 | ||
|
|
4d06a2722c | ||
|
|
b9f5bee35e | ||
|
|
72078b2768 | ||
|
|
465191609b | ||
|
|
6e1858a55e | ||
|
|
3eeb947914 | ||
|
|
d2c99fa8b7 | ||
|
|
dbba96426d | ||
|
|
a5be35a667 | ||
|
|
6e291a8df1 | ||
|
|
24898cf9fb | ||
|
|
66e64ac305 | ||
|
|
ee6bcc864f | ||
|
|
8de49d670b | ||
|
|
13b1c699f6 | ||
|
|
86a70ffb48 | ||
|
|
2bb71238bc | ||
|
|
7d491de86c | ||
|
|
02575a917b | ||
|
|
5206851b22 | ||
|
|
74c36f27ae | ||
|
|
2048a2bb9b | ||
|
|
ea48cdb4f3 | ||
|
|
942b9f3fb4 | ||
|
|
5f6d635c7a | ||
|
|
47a398f518 | ||
|
|
9fdd72220f | ||
|
|
0a1913e056 | ||
|
|
68db3dcda6 | ||
|
|
c50c9c8528 | ||
|
|
8a418f6649 | ||
|
|
acdc924047 | ||
|
|
2fa1d3f06f | ||
|
|
7ffa225487 | ||
|
|
c1659eb2dd | ||
|
|
1e888494ee | ||
|
|
054ba13dc3 | ||
|
|
301760c39f | ||
|
|
79fd814c14 | ||
|
|
a82d0e7063 | ||
|
|
c6ea1182e0 | ||
|
|
5e7b79eb89 | ||
|
|
3738d94789 | ||
|
|
fb0e372917 | ||
|
|
8945f9e33f | ||
|
|
2895e7ffe7 | ||
|
|
f102166fae | ||
|
|
c51e28b8e1 | ||
|
|
a9923eb8c0 | ||
|
|
de2f3964e0 | ||
|
|
7c5ccbf63b | ||
|
|
b4b96222ed | ||
|
|
a920f899fb | ||
|
|
31b39a49d6 | ||
|
|
bfb4e9741a | ||
|
|
bc5c5a13b4 | ||
|
|
19c50f40cd | ||
|
|
bb0a987d80 | ||
|
|
dba380e4c4 | ||
|
|
4a52524212 | ||
|
|
1a94ba5a40 | ||
|
|
2022255200 | ||
|
|
ab1951f9c5 | ||
|
|
a9176b8508 | ||
|
|
02ad998b06 | ||
|
|
31059abf23 | ||
|
|
fc70931c2c | ||
|
|
af4a6c4672 | ||
|
|
3347224018 | ||
|
|
558062a915 | ||
|
|
a7ab941543 | ||
|
|
57b5f33720 | ||
|
|
f36d0ef5e6 | ||
|
|
0d9b0fec6b | ||
|
|
d1b83410d0 | ||
|
|
990bb26276 | ||
|
|
4f4b480a13 | ||
|
|
b2490cd014 | ||
|
|
9d85563cf2 | ||
|
|
ac573d9311 | ||
|
|
1e5ae4048c | ||
|
|
3db05fb3b1 | ||
|
|
5a098e125a | ||
|
|
78b77fd4c1 | ||
|
|
b2470ca89b | ||
|
|
4764698583 | ||
|
|
c91a34aae5 | ||
|
|
8dc76f6ce5 | ||
|
|
0ebe949da6 | ||
|
|
6c1a40d614 | ||
|
|
9a81d06e2d | ||
|
|
687cd210af | ||
|
|
87135c0e1b | ||
|
|
48cfa6b7f1 | ||
|
|
25c78eed91 | ||
|
|
6a50387c39 | ||
|
|
bac6f65dd0 | ||
|
|
5ec34fea3b | ||
|
|
539e53c6b1 | ||
|
|
dfe108d13e | ||
|
|
37e6c87046 | ||
|
|
e92929910d | ||
|
|
7488afdbb3 | ||
|
|
2dbc99a27a | ||
|
|
1dd26feebb | ||
|
|
203847dcd7 | ||
|
|
a4a3620f71 | ||
|
|
5fba9bfe7b | ||
|
|
c05aa75f1e | ||
|
|
2f378aaeec | ||
|
|
e92279634d | ||
|
|
1bc15b7b7c | ||
|
|
7d8083b268 | ||
|
|
7035159fa6 | ||
|
|
a2a9446e3b | ||
|
|
5c40fedde2 | ||
|
|
a44ca13e3a | ||
|
|
67f3595d33 | ||
|
|
ae10d433e6 | ||
|
|
e33768d9ca | ||
|
|
7624fa204d | ||
|
|
4738674f7f | ||
|
|
7cbc96161e | ||
|
|
d940ba252e | ||
|
|
60507cc817 | ||
|
|
3a99c28233 | ||
|
|
7e91628339 | ||
|
|
eb053eb5bf | ||
|
|
7462b1a2f7 | ||
|
|
8d557c3898 | ||
|
|
6b1c53c31a | ||
|
|
5e27937358 | ||
|
|
a8aad809ba | ||
|
|
d94ca0e9c6 | ||
|
|
aa51535e65 | ||
|
|
7737c82db9 | ||
|
|
7ee64e0ead | ||
|
|
987990704c | ||
|
|
e8975e70f5 | ||
|
|
72409a0b73 | ||
|
|
3dfd2d7862 | ||
|
|
c93d2f73a9 | ||
|
|
66d638f952 | ||
|
|
815a8f186b | ||
|
|
ae745b917e | ||
|
|
bd759c53cd | ||
|
|
8dca324f71 | ||
|
|
dd7a7d9977 | ||
|
|
a7c81a77f2 | ||
|
|
15b1e68611 | ||
|
|
a5e25e2eb5 | ||
|
|
c10ec27b51 | ||
|
|
9c86a9124d | ||
|
|
069c5a4389 | ||
|
|
b498cf4b62 | ||
|
|
70ee848a41 | ||
|
|
60fd6a4af4 | ||
|
|
04ed687dbb | ||
|
|
d694415a4a | ||
|
|
3f2a9c999b | ||
|
|
ea40650214 | ||
|
|
ae66aced83 | ||
|
|
5d2eebf093 | ||
|
|
5c8057d71f | ||
|
|
4d78a73602 | ||
|
|
7c5a9eb683 | ||
|
|
df423d28d6 | ||
|
|
a92616c5a3 | ||
|
|
2fc29f65ca | ||
|
|
aab712e680 | ||
|
|
4bca86d91e | ||
|
|
63cca3eba2 | ||
|
|
cba2ed5944 | ||
|
|
ee5886c9b9 | ||
|
|
e96dadae34 | ||
|
|
a23ad82b60 | ||
|
|
f816580886 | ||
|
|
a5f8f1a4c2 | ||
|
|
0d47f06be3 | ||
|
|
c4eb2d2116 | ||
|
|
8c1dc62211 | ||
|
|
b1a66bb8dd | ||
|
|
61a2cefcc3 | ||
|
|
ad775bf8e1 | ||
|
|
a80e8bb298 | ||
|
|
2db51746ac | ||
|
|
3d7e6d7f4c | ||
|
|
6918cf5f76 | ||
|
|
cd05ed4da9 | ||
|
|
b669900c75 | ||
|
|
3a9bf04a4d | ||
|
|
0cd4257e73 | ||
|
|
6f55da754c | ||
|
|
31adb45152 | ||
|
|
68a20e0d11 | ||
|
|
52657b0c08 | ||
|
|
a2123b7a5d | ||
|
|
207f98ca17 | ||
|
|
58170b6072 | ||
|
|
744955bf11 | ||
|
|
cd6d53d19e | ||
|
|
50e03fbd50 | ||
|
|
6dd219ee05 | ||
|
|
2a6cd96c8c | ||
|
|
1323668fe6 | ||
|
|
24b747eb38 | ||
|
|
ed31d1fefb | ||
|
|
237a06f1cf | ||
|
|
06528e7504 | ||
|
|
387f9a2601 | ||
|
|
4bcfd083d6 | ||
|
|
0e922176fe | ||
|
|
250e7c20e5 | ||
|
|
c3de960966 | ||
|
|
866995b2d3 | ||
|
|
825ee23afe | ||
|
|
66ce35927a | ||
|
|
468d363c65 | ||
|
|
009744cb47 | ||
|
|
2d63a1e79f | ||
|
|
5452c55e86 | ||
|
|
6b164f84d9 | ||
|
|
bf104a1d85 | ||
|
|
1d32ec810f | ||
|
|
1f7b9dbbc4 | ||
|
|
8c0bfc2143 | ||
|
|
279ddd9974 | ||
|
|
71b29c0b56 | ||
|
|
b331a85a2a | ||
|
|
e8037e8d60 | ||
|
|
ea0dd4cb78 | ||
|
|
4734ab4a22 | ||
|
|
4df97acd73 | ||
|
|
8ec233d761 | ||
|
|
135a1fc5ed | ||
|
|
4cb5b09001 | ||
|
|
3bb8b51cb5 | ||
|
|
1ee31873ab | ||
|
|
79fac06a4c | ||
|
|
553bbedc95 | ||
|
|
d31fe53f78 | ||
|
|
be74e52156 | ||
|
|
a2157ae618 | ||
|
|
8b7bcaa92f | ||
|
|
157998ddf5 | ||
|
|
cd302bb1bb | ||
|
|
52341757e4 | ||
|
|
a74633df54 | ||
|
|
b936b54132 | ||
|
|
cf111f7852 | ||
|
|
f23f9498cd | ||
|
|
7c019353df | ||
|
|
3954e63141 | ||
|
|
d04d638b27 | ||
|
|
3ad414dadd | ||
|
|
8bc9bd4dbb | ||
|
|
b0c3066adc | ||
|
|
c5dfd4fffe | ||
|
|
7de7b4f843 | ||
|
|
7079c5a6f1 | ||
|
|
46ebda2673 | ||
|
|
eee9df7d8a | ||
|
|
f8be557e6e | ||
|
|
d6f776e12e | ||
|
|
6fd3f32f7e | ||
|
|
adbb400728 | ||
|
|
8f6a46aa2e | ||
|
|
0ec52b84fe | ||
|
|
b436f10e18 | ||
|
|
75ebf57c68 | ||
|
|
b3cea28687 | ||
|
|
878b51fa30 | ||
|
|
81f024d197 | ||
|
|
a21d665f5d | ||
|
|
954bc9710b | ||
|
|
5faeef190d | ||
|
|
43c5f93b60 | ||
|
|
adfac94094 | ||
|
|
bc60929fe9 | ||
|
|
fdf2564f15 | ||
|
|
8b6d422d96 | ||
|
|
d2c0fea75f | ||
|
|
efa5afb441 | ||
|
|
830144d5b1 | ||
|
|
f6b684ceb0 | ||
|
|
cd3dfc065b | ||
|
|
72c1529bb9 | ||
|
|
1b17da668d | ||
|
|
3e7f50f4bf | ||
|
|
12bc6a06fb | ||
|
|
c3f146723a | ||
|
|
f64254ccef | ||
|
|
0a43d9c9f6 | ||
|
|
dcb9bfeb18 | ||
|
|
d6f5ea444d | ||
|
|
3a3e02a937 | ||
|
|
b5109fc872 | ||
|
|
d9ed25478f | ||
|
|
f5c9da6aa0 | ||
|
|
293c04b2ad | ||
|
|
e91b344bea | ||
|
|
491d8a6e71 | ||
|
|
04054914f5 | ||
|
|
d273c6f7b5 | ||
|
|
9cf134e916 | ||
|
|
abd1e73d82 | ||
|
|
5625aebb0c | ||
|
|
b84393c0d6 | ||
|
|
c0021ff5dc | ||
|
|
9e1893612c | ||
|
|
3c2c577628 | ||
|
|
190b7b4026 | ||
|
|
6fa03bad66 | ||
|
|
0a84659b70 | ||
|
|
dec5873682 | ||
|
|
8fc18f8324 | ||
|
|
590240b283 | ||
|
|
4b90ff49fd | ||
|
|
2223cea395 | ||
|
|
ad692b73ba | ||
|
|
de57d089eb | ||
|
|
303a706d8a | ||
|
|
108bbbda87 | ||
|
|
65f060a8d2 | ||
|
|
f35f0af6d8 | ||
|
|
6c30892def | ||
|
|
1016c85e3c | ||
|
|
887c05c2b9 | ||
|
|
98cb50fab7 | ||
|
|
38d75b0e51 | ||
|
|
20a2389eb0 | ||
|
|
9cbc754574 | ||
|
|
b564283419 | ||
|
|
32dd218131 | ||
|
|
09a36aa4c6 | ||
|
|
c1f1d67bfa | ||
|
|
93d2397572 | ||
|
|
4b89dc202e | ||
|
|
0718e18fb0 | ||
|
|
90483889c4 | ||
|
|
b7ef4cb2d6 | ||
|
|
b73261a339 | ||
|
|
0b4fd240cc | ||
|
|
b1af5d2349 | ||
|
|
456205dece | ||
|
|
8d092a761a | ||
|
|
05e9c25742 | ||
|
|
998171af9e | ||
|
|
3c62dd9542 | ||
|
|
55cc397333 | ||
|
|
b969b5f0bf | ||
|
|
d7cc55ed53 | ||
|
|
9ab8a3e149 | ||
|
|
0ae48dee33 | ||
|
|
d612ed44d9 | ||
|
|
cb798cf011 | ||
|
|
8392841554 | ||
|
|
24d4d3279a | ||
|
|
84bdcb340b | ||
|
|
e4a28684ef | ||
|
|
0f5698bb58 | ||
|
|
ca2da81af5 | ||
|
|
a26fd0862f | ||
|
|
ab905d4061 | ||
|
|
7c27e26da7 | ||
|
|
7f49333c42 | ||
|
|
50d2af7be4 |
@@ -6,3 +6,6 @@ cbdfa67d075ec1f8ceec0f3559498cbca01783a0
|
||||
|
||||
# Renamed Renovate config file
|
||||
f304304bc5b64edf189aaa473b05df912067a8bd
|
||||
|
||||
# Refactored database module into core and impl
|
||||
7b0b206905b8dc1f824a2d2a10e3d94c6b9dd11c
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
**/dependencies/** linguist-generated
|
||||
**/clawicons/** linguist-generated
|
||||
android/src/main/baseline-prof.txt linguist-generated -diff
|
||||
android/src/main/generated/** linguist-generated -diff
|
||||
|
||||
|
Before Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 288 KiB |
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:base",
|
||||
":dependencyDashboard",
|
||||
@@ -15,15 +16,25 @@
|
||||
"https://maven.google.com/",
|
||||
"https://repo1.maven.org/maven2",
|
||||
"https://plugins.gradle.org/m2/",
|
||||
"https://jitpack.io",
|
||||
],
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": [
|
||||
"^kotlin",
|
||||
"^org.jetbrains.kotlin",
|
||||
"^androidx.compose.compiler"
|
||||
"matchPackageNames": [
|
||||
"androidx.compose.compiler:compiler",
|
||||
"dev.drewhamilton.poko",
|
||||
"org.jetbrains.kotlin:kotlin-gradle-plugin",
|
||||
"org.jetbrains.kotlin.plugin.serialization",
|
||||
],
|
||||
"groupName": "kotlin"
|
||||
"groupName": "Kotlin and Compose",
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": [
|
||||
"^androidx.*",
|
||||
],
|
||||
"matchPackageNames": [
|
||||
],
|
||||
"groupName": "androidx"
|
||||
},
|
||||
{
|
||||
"matchPackagePatterns": [
|
||||
@@ -33,16 +44,12 @@
|
||||
"groupName": "sentry"
|
||||
},
|
||||
{
|
||||
"managers": [
|
||||
"gradle"
|
||||
"matchPackageNames": [
|
||||
"com.android.tools:r8",
|
||||
"com.squareup.anvil",
|
||||
"dev.chrisbanes.compose:compose-bom",
|
||||
],
|
||||
"packagePatterns": [
|
||||
// We want to stay in sync with Retrofit
|
||||
"^com.squareup.okhttp3",
|
||||
// Renovate refuses to stop resolving this from the snapshots repo
|
||||
"^app.cash.sqldelight",
|
||||
],
|
||||
"enabled": false
|
||||
"enabled": false,
|
||||
},
|
||||
],
|
||||
"regexManagers": [
|
||||
@@ -57,17 +64,6 @@
|
||||
"depNameTemplate": "com.facebook:ktfmt",
|
||||
"registryUrlTemplate": "https://repo1.maven.org/maven2/",
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"Detekt\.kt$"
|
||||
],
|
||||
"matchStrings": [
|
||||
"COMPOSE_RULES_VERSION = \"(?<currentValue>.*)\""
|
||||
],
|
||||
"datasourceTemplate": "maven",
|
||||
"depNameTemplate": "io.nlopez.compose.rules:detekt",
|
||||
"registryUrlTemplate": "https://repo1.maven.org/maven2/",
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"gradle/libs.versions.toml"
|
||||
@@ -79,26 +75,5 @@
|
||||
"depNameTemplate": "androidx.compose.compiler:compiler",
|
||||
"registryUrlTemplate": "https://maven.google.com",
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"qodana.yaml"
|
||||
],
|
||||
"matchStrings": [
|
||||
"jetbrains/qodana-jvm-android:(?<currentValue>.*)"
|
||||
],
|
||||
"datasourceTemplate": "docker",
|
||||
"depNameTemplate": "jetbrains/qodana-jvm-android",
|
||||
},
|
||||
{
|
||||
"fileMatch": [
|
||||
"gradle/libs.versions.toml"
|
||||
],
|
||||
"matchStrings": [
|
||||
"sentry-sdk = \"(?<currentValue>.*)\""
|
||||
],
|
||||
"datasourceTemplate": "maven",
|
||||
"depNameTemplate": "io.sentry:sentry-android",
|
||||
"registryUrlTemplate": "https://repo1.maven.org/maven2/",
|
||||
}
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
name: Baseline profile generation
|
||||
|
||||
on:
|
||||
# every day at 00:43
|
||||
schedule:
|
||||
- cron: '43 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
AVD_API_LEVEL: 31
|
||||
AVD_ARCH: x86_64
|
||||
|
||||
jobs:
|
||||
baseline-profile:
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
TERM: dumb
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
|
||||
with:
|
||||
token: ${{ secrets.POST_RELEASE_GH_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@3f07048e3d294f56e9b90ac5ea2c6f74e9ad0f98 # v3.10.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 18
|
||||
|
||||
- name: Set up Git author
|
||||
shell: bash
|
||||
run: |
|
||||
git config user.name "GitHub Actions"
|
||||
git config user.email noreply@github.com
|
||||
|
||||
- name: Setup Gradle caching
|
||||
uses: gradle/gradle-build-action@6095a76664413da4c8c134ee32e8a8ae900f0f1f # v2.4.0
|
||||
with:
|
||||
gradle-home-cache-cleanup: true
|
||||
|
||||
# Restore AVD from cache
|
||||
- name: Cache AVD
|
||||
uses: actions/cache@69d9d449aced6a2ede0bc19182fadc3a0a42d2b0 # v3.2.6
|
||||
id: avd-cache
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/*
|
||||
~/.android/adb*
|
||||
key: ${{ runner.os }}-avd-${{ env.AVD_API_LEVEL }}-${{ env.AVD_ARCH }}
|
||||
|
||||
- name: Decrypt secrets
|
||||
run: |
|
||||
./scripts/setup-age.sh
|
||||
./scripts/signing-setup.sh "$ENCRYPT_KEY"
|
||||
env:
|
||||
ENCRYPT_KEY: ${{ secrets.ENCRYPT_KEY }}
|
||||
|
||||
# This allows us to build most of what we need without the emulator running
|
||||
# and using resources
|
||||
- name: Build app and benchmark
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
run: ./gradlew :benchmark:assembleBenchmark :android:assembleBenchmark
|
||||
|
||||
# Now use reactivecircus/android-emulator-runner to spin up an emulator. We're gonna use it again
|
||||
# to actually run the test once the emulator has booted.
|
||||
- name: Create AVD snapshot for caching
|
||||
if: steps.avd-cache.outputs.cache-hit != 'true'
|
||||
uses: reactivecircus/android-emulator-runner@50986b1464923454c95e261820bc626f38490ec0 # v2
|
||||
with:
|
||||
api-level: ${{ env.AVD_API_LEVEL }}
|
||||
arch: ${{ env.AVD_ARCH }}
|
||||
channel: canary
|
||||
target: aosp_atd
|
||||
force-avd-creation: false
|
||||
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
|
||||
disable-animations: false
|
||||
disk-size: 8G
|
||||
script: echo "Generated AVD snapshot for caching."
|
||||
|
||||
# Run our baseline profile generator. We need to manually pull the baseline profiles off the
|
||||
# emulator when using the GA runner
|
||||
- name: Run benchmark
|
||||
uses: reactivecircus/android-emulator-runner@50986b1464923454c95e261820bc626f38490ec0 # v2
|
||||
with:
|
||||
api-level: ${{ env.AVD_API_LEVEL }}
|
||||
arch: ${{ env.AVD_ARCH }}
|
||||
channel: canary
|
||||
target: aosp_atd
|
||||
disable-animations: true
|
||||
disable-spellchecker: true
|
||||
disk-size: 8G
|
||||
script: |
|
||||
# Run our benchmark, enabling only tests using BaselineProfile
|
||||
./gradlew connectedBenchmarkAndroidTest -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile --stacktrace
|
||||
# Need to manually pull the generated profiles from the emulator
|
||||
adb pull /sdcard/Android/media/dev.msfjarvis.claw.benchmark benchmark/build/outputs/baseline-prof/
|
||||
|
||||
# If we're on main branch, copy over the baseline profile and
|
||||
# commit it to the repository (if changed)
|
||||
- name: Commit baseline profile into main
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
# Pull down any changes which may have been committed while this workflow has been running
|
||||
git pull
|
||||
# Sort the baseline profile, saving it to app/
|
||||
sort -o android/src/main/baseline-prof.txt benchmark/build/outputs/baseline-prof/BaselineProfileGenerator_startup-baseline-prof.txt
|
||||
# If the baseline profile has changed, commit it
|
||||
if [[ $(git diff --stat android/src/main/baseline-prof.txt) != '' ]]; then
|
||||
git add android/src/main/baseline-prof.txt
|
||||
git commit -m "chore(android): refresh baseline profile" && git push
|
||||
fi
|
||||
|
||||
# Upload the entire generated folder and attach it to the CI run
|
||||
- name: Attach baseline profile
|
||||
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
|
||||
with:
|
||||
name: Baseline profile output
|
||||
path: benchmark/build/outputs/baseline-prof
|
||||
|
||||
- name: Clean secrets
|
||||
if: always()
|
||||
run: scripts/signing-cleanup.sh
|
||||
@@ -18,45 +18,57 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@3f07048e3d294f56e9b90ac5ea2c6f74e9ad0f98 # v3.10.0
|
||||
uses: actions/setup-java@0ab4596768b603586c0de567f2430c30f5b0d2b0 # v3.13.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 18
|
||||
|
||||
- name: Run unit tests
|
||||
uses: gradle/gradle-build-action@6095a76664413da4c8c134ee32e8a8ae900f0f1f # v2.4.0
|
||||
uses: gradle/gradle-build-action@842c587ad8aa4c68eeba24c396e15af4c2e9f30a # v2.9.0
|
||||
with:
|
||||
arguments: --no-configuration-cache --stacktrace check
|
||||
gradle-home-cache-cleanup: true
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
- name: Run Detekt
|
||||
uses: gradle/gradle-build-action@6095a76664413da4c8c134ee32e8a8ae900f0f1f # v2.4.0
|
||||
with:
|
||||
arguments: --no-configuration-cache --stacktrace detektMain
|
||||
arguments: --no-configuration-cache --stacktrace check -PslimTests
|
||||
gradle-home-cache-cleanup: true
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/main' }}
|
||||
dependency-graph: generate-and-submit
|
||||
|
||||
- name: (Fail-only) Upload test report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
|
||||
with:
|
||||
name: Test report
|
||||
path: android/build/reports
|
||||
|
||||
dependency-review:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- check
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@6c5ccdad469c9f8a2996bfecaec55a631a347034 # v3
|
||||
with:
|
||||
base-ref: refs/heads/main
|
||||
head-ref: ${{ github.ref }}
|
||||
|
||||
deploy-release-snapshot:
|
||||
runs-on: ubuntu-latest
|
||||
if: "github.event_name == 'push' && github.event.ref == 'refs/heads/main'"
|
||||
needs: [ "check" ]
|
||||
needs:
|
||||
- check
|
||||
- dependency-review
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@3f07048e3d294f56e9b90ac5ea2c6f74e9ad0f98 # v3.10.0
|
||||
uses: actions/setup-java@0ab4596768b603586c0de567f2430c30f5b0d2b0 # v3.13.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 18
|
||||
@@ -64,12 +76,12 @@ jobs:
|
||||
- name: Decrypt secrets
|
||||
run: |
|
||||
./scripts/setup-age.sh
|
||||
./scripts/signing-setup.sh "$ENCRYPT_KEY"
|
||||
./scripts/signing-setup.sh "$AGE_SECRET_KEY"
|
||||
env:
|
||||
ENCRYPT_KEY: ${{ secrets.ENCRYPT_KEY }}
|
||||
AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
|
||||
|
||||
- name: Build release app
|
||||
uses: gradle/gradle-build-action@6095a76664413da4c8c134ee32e8a8ae900f0f1f # v2.4.0
|
||||
uses: gradle/gradle-build-action@842c587ad8aa4c68eeba24c396e15af4c2e9f30a # v2.9.0
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
with:
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
name: "CodeQL"
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- renovate/**
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '31 7 * * 3'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
@@ -13,14 +21,33 @@ jobs:
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@0ab4596768b603586c0de567f2430c30f5b0d2b0 # v3.13.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 18
|
||||
|
||||
- name: Setup Gradle caching
|
||||
uses: gradle/gradle-build-action@842c587ad8aa4c68eeba24c396e15af4c2e9f30a # v2.9.0
|
||||
with:
|
||||
gradle-home-cache-cleanup: true
|
||||
cache-read-only: true
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@32dc499307d133bb5085bae78498c0ac2cf762d5 # v2
|
||||
uses: github/codeql-action/init@ddccb873888234080b77e9bc2d4764d5ccaaccf9 # v2.21.9
|
||||
with:
|
||||
languages: java
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@32dc499307d133bb5085bae78498c0ac2cf762d5 # v2
|
||||
tools: latest
|
||||
queries: +security-extended
|
||||
|
||||
- name: Build project
|
||||
shell: bash
|
||||
run: |
|
||||
./gradlew assembleDebug assembleRelease
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@32dc499307d133bb5085bae78498c0ac2cf762d5 # v2
|
||||
uses: github/codeql-action/analyze@ddccb873888234080b77e9bc2d4764d5ccaaccf9 # v2.21.9
|
||||
with:
|
||||
category: "/language:java"
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
name: Qodana
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
qodana:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: 'Qodana Scan'
|
||||
uses: JetBrains/qodana-action@7afb26c0c2f325c0d5c21ea1f617c79c7f899337 # v2022.3.4
|
||||
env:
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
@@ -1,19 +1,28 @@
|
||||
name: Release to Google Play
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
inAppUpdatePriority:
|
||||
description: 'In app update priority (0-5)'
|
||||
type: number
|
||||
default: "0"
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
publish-google-play-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
|
||||
with:
|
||||
token: ${{ secrets.POST_RELEASE_GH_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@3f07048e3d294f56e9b90ac5ea2c6f74e9ad0f98 # v3.10.0
|
||||
uses: actions/setup-java@0ab4596768b603586c0de567f2430c30f5b0d2b0 # v3.13.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 18
|
||||
@@ -25,7 +34,7 @@ jobs:
|
||||
git config user.email noreply@github.com
|
||||
|
||||
- name: Setup Gradle caching
|
||||
uses: gradle/gradle-build-action@6095a76664413da4c8c134ee32e8a8ae900f0f1f # v2.4.0
|
||||
uses: gradle/gradle-build-action@842c587ad8aa4c68eeba24c396e15af4c2e9f30a # v2.9.0
|
||||
with:
|
||||
gradle-home-cache-cleanup: true
|
||||
cache-read-only: true
|
||||
@@ -33,9 +42,9 @@ jobs:
|
||||
- name: Decrypt secrets
|
||||
run: |
|
||||
./scripts/setup-age.sh
|
||||
./scripts/signing-setup.sh "$ENCRYPT_KEY"
|
||||
./scripts/signing-setup.sh "$AGE_SECRET_KEY"
|
||||
env:
|
||||
ENCRYPT_KEY: ${{ secrets.ENCRYPT_KEY }}
|
||||
AGE_SECRET_KEY: ${{ secrets.AGE_SECRET_KEY }}
|
||||
|
||||
- name: Get current version
|
||||
shell: bash
|
||||
@@ -45,7 +54,7 @@ jobs:
|
||||
echo VERSION="${VERSION}" >> $GITHUB_ENV
|
||||
|
||||
- name: Update changelog
|
||||
uses: msfjarvis/keep-a-changelog-new-release@ce8769801992253adb0209055c236a0cd192500b # 2.0.0
|
||||
uses: msfjarvis/keep-a-changelog-new-release@bf43ae42f4e42f137572950af426efcf6e777207 # 2.1.0
|
||||
with:
|
||||
tag: v${{ env.VERSION }}
|
||||
version: ${{ env.VERSION }}
|
||||
@@ -83,7 +92,7 @@ jobs:
|
||||
printf '%s' "${CHANGELOG}" >> distribution/whatsnew/whatsnew-en-GB
|
||||
|
||||
- name: Publish bundle to Google Play
|
||||
uses: r0adkll/upload-google-play@70c7397e868b00eccd390222f492ab3e30520d09 # v1.1.1
|
||||
uses: r0adkll/upload-google-play@9cf8fd30c9e616ec761db3b9abb2b0f15842ce58 # v1.1.2
|
||||
with:
|
||||
mappingFile: android/bundle/mapping.txt
|
||||
packageName: dev.msfjarvis.claw.android
|
||||
@@ -92,6 +101,7 @@ jobs:
|
||||
status: completed
|
||||
track: production
|
||||
whatsNewDirectory: distribution/whatsnew
|
||||
inAppUpdatePriority: ${{ inputs.inAppUpdatePriority }}
|
||||
|
||||
- name: Post-release work
|
||||
shell: bash
|
||||
|
||||
@@ -245,3 +245,5 @@ hs_err_pid*
|
||||
|
||||
keystore.properties
|
||||
distribution/
|
||||
screenshots/
|
||||
claw-export.json
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
*.json
|
||||
baseline-prof.txt
|
||||
**/test/resources/**
|
||||
@@ -7,6 +7,145 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.37.0] - 2023-10-06
|
||||
|
||||
### Changed
|
||||
|
||||
* Fixed a crash that infrequently happened in the comments page
|
||||
* Fixed a crash when trying to list saved posts
|
||||
* Fixed a crash in home screen widget when user had less than 50 saved posts
|
||||
* Upgraded to Compose 1.6.0-alpha07
|
||||
|
||||
## [1.36.0] - 2023-10-04
|
||||
|
||||
### Added
|
||||
|
||||
* Introduce an initial attempt at a home screen widget
|
||||
|
||||
### Changed
|
||||
|
||||
* Fixed a crash triggered when swiping items
|
||||
* Adjusted pull to refresh component to match Material You theme
|
||||
|
||||
## [1.35.0] - 2023-09-19
|
||||
|
||||
### Changed
|
||||
|
||||
* Fixed missing vertical spacing between post tags
|
||||
* Fixed user profile links not displaying correctly in-app
|
||||
* Update to Compose October release
|
||||
|
||||
## [1.34.0] - 2023-08-30
|
||||
|
||||
### Added
|
||||
|
||||
* Add a swipe action on each story to open the comments page on `lobste.rs`
|
||||
* Automatically mark posts as "read" and visually distinguish between them
|
||||
|
||||
### Changed
|
||||
|
||||
* Tweak list items to reduce vertical size and adjust colors
|
||||
* Drop bogus workarounds for native library crashes
|
||||
* Updated Jetpack Compose and SQLite
|
||||
|
||||
### Fixed
|
||||
|
||||
* Rework how comments are displayed to adapt to lobste.rs API change
|
||||
|
||||
## [1.33.0] - 2023-07-31
|
||||
|
||||
### Changed
|
||||
|
||||
* Yet another attempt at fixing native library crashes
|
||||
* Rework how baseline profiles are generated
|
||||
|
||||
## [1.32.0] - 2023-07-27
|
||||
|
||||
### Changed
|
||||
|
||||
* Upgrade to AndroidX July 26 release
|
||||
* Revert selectable text feature due to sporadic crashes
|
||||
|
||||
## [1.31.0] - 2023-07-25
|
||||
|
||||
### Added
|
||||
|
||||
* Added the ability to search for posts
|
||||
* Text in the comments page is now selectable
|
||||
|
||||
### Changed
|
||||
|
||||
* Upgrade to Compose July release
|
||||
* Upgrade to Kotlin 1.9.0
|
||||
|
||||
## [1.30.0] - 2023-07-02
|
||||
|
||||
### Changed
|
||||
|
||||
* Added another workaround for native library loading crash
|
||||
|
||||
## [1.29.0] - 2023-06-08
|
||||
|
||||
### Added
|
||||
|
||||
* Backup and restore options for saved posts
|
||||
|
||||
### Fixed
|
||||
|
||||
* Spamming the comments button no longer causes it to be opened multiple times
|
||||
* Saved posts screen now has a visual indication when you have nothing saved
|
||||
* Iconography has been updated across the board to be more consistent
|
||||
|
||||
## [1.28.0] - 2023-06-03
|
||||
|
||||
### Changed
|
||||
|
||||
* Navigation transitions have been slightly sped up
|
||||
|
||||
### Fixed
|
||||
|
||||
* Add workaround for a native library loading crash observed on some devices
|
||||
|
||||
## [1.27.0] - 2023-05-31
|
||||
|
||||
### Changed
|
||||
|
||||
* Small accessibility improvements
|
||||
* Slightly tweak the layout for story items to take less vertical space
|
||||
* Upgrade to Compose `1.5.0-beta01` release
|
||||
* Set accessibility web URI for profile screen
|
||||
* Directly use Material Icons from upstream artifacts
|
||||
* Upgrade dependencies
|
||||
* Add adaptive navigation support (thanks @Yash-Garg)
|
||||
* Use latest SQLite for backing databases
|
||||
|
||||
## [1.26.0] - 2023-05-03
|
||||
|
||||
### Changed
|
||||
|
||||
* Upgrade to Compose May release
|
||||
* Disable Sentry performance reporting
|
||||
* Migrate to Compose Foundation `FlowRow` in `LobstersCard`
|
||||
|
||||
## [1.25.0] - 2023-04-18
|
||||
|
||||
### Changed
|
||||
|
||||
* Make the app draw edge to edge
|
||||
* Simplify Top App Bar color scheme
|
||||
* Rework how baseline profiles are generated
|
||||
* Upgrade to Compose April release
|
||||
|
||||
## [1.24.0] - 2023-03-24
|
||||
|
||||
### Changed
|
||||
|
||||
* Rebuild app icon assets to align better with Material guidelines
|
||||
* Adopt Slack's Compose lints and address issues found by it
|
||||
* Upgrade to OkHttp 4.x
|
||||
* Upgrade to Compose March release
|
||||
* Start work on support for logging in with lobste.rs
|
||||
|
||||
## [1.23.0] - 2023-03-02
|
||||
|
||||
### Added
|
||||
@@ -182,7 +321,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* Initial Play Store release
|
||||
|
||||
[Unreleased]: https://github.com/msfjarvis/compose-lobsters/compare/v1.23.0...HEAD
|
||||
[Unreleased]: https://github.com/msfjarvis/compose-lobsters/compare/v1.37.0...HEAD
|
||||
|
||||
[1.37.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.36.0...v1.37.0
|
||||
|
||||
[1.36.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.35.0...v1.36.0
|
||||
|
||||
[1.35.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.34.0...v1.35.0
|
||||
|
||||
[1.34.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.33.0...v1.34.0
|
||||
|
||||
[1.33.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.32.0...v1.33.0
|
||||
|
||||
[1.32.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.31.0...v1.32.0
|
||||
|
||||
[1.31.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.30.0...v1.31.0
|
||||
|
||||
[1.30.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.29.0...v1.30.0
|
||||
|
||||
[1.29.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.28.0...v1.29.0
|
||||
|
||||
[1.28.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.27.0...v1.28.0
|
||||
|
||||
[1.27.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.26.0...v1.27.0
|
||||
|
||||
[1.26.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.25.0...v1.26.0
|
||||
|
||||
[1.25.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.24.0...v1.25.0
|
||||
|
||||
[1.24.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.23.0...v1.24.0
|
||||
|
||||
[1.23.0]: https://github.com/msfjarvis/compose-lobsters/compare/v1.22.0...v1.23.0
|
||||
|
||||
|
||||
@@ -8,12 +8,40 @@ Unofficial Android app for read-only access to [lobste.rs](https://lobste.rs), b
|
||||
height="80" />
|
||||
</a>
|
||||
|
||||
<img src="https://github.com/msfjarvis/compose-lobsters/blob/main/.github/readme_feature.webp"
|
||||
alt="Side by side screenshots of the app's main page in dark and light UI modes"
|
||||
height="550" />
|
||||
> Snapshots from the development branch can be obtained [here](https://github.com/msfjarvis/compose-lobsters/releases/tag/nightly).
|
||||
|
||||
Snapshots from the development branch can be obtained [here](https://github.com/msfjarvis/compose-lobsters/releases/tag/nightly).
|
||||
<img src="https://github.com/msfjarvis/compose-lobsters/blob/main/.github/readme_feature_light.webp#gh-light-mode-only"
|
||||
alt="A grid of screenshots from the app, in clockwise order: the main screen, the comments page, the search page and the saved posts page"
|
||||
width="550" />
|
||||
|
||||
<img src="https://github.com/msfjarvis/compose-lobsters/blob/main/.github/readme_feature_dark.webp#gh-dark-mode-only"
|
||||
alt="A grid of screenshots from the app, in clockwise order: the main screen, the comments page, the search page and the saved posts page"
|
||||
width="550" />
|
||||
|
||||
## Module dependency graph
|
||||
|
||||
```mermaid
|
||||
%%{
|
||||
init: {
|
||||
'theme': 'dark'
|
||||
}
|
||||
}%%
|
||||
|
||||
graph TB
|
||||
|
||||
model --> database
|
||||
benchmark --> android
|
||||
android --> api
|
||||
android --> common
|
||||
android --> core
|
||||
android --> database
|
||||
android --> model
|
||||
android --> benchmark
|
||||
api --> model
|
||||
common --> core
|
||||
common --> database
|
||||
common --> model
|
||||
```
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
-dontobfuscate
|
||||
@@ -4,7 +4,9 @@
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
@file:Suppress("DSL_SCOPE_VIOLATION", "UnstableApiUsage")
|
||||
@file:Suppress("UnstableApiUsage")
|
||||
|
||||
import dev.msfjarvis.claw.gradle.addTestDependencies
|
||||
|
||||
plugins {
|
||||
id("dev.msfjarvis.claw.android-application")
|
||||
@@ -14,7 +16,45 @@ plugins {
|
||||
id("dev.msfjarvis.claw.sentry")
|
||||
id("dev.msfjarvis.claw.versioning-plugin")
|
||||
alias(libs.plugins.anvil)
|
||||
alias(libs.plugins.modulegraphassert)
|
||||
alias(libs.plugins.whetstone)
|
||||
alias(libs.plugins.baselineprofile)
|
||||
alias(libs.plugins.licensee)
|
||||
alias(libs.plugins.tracelog)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "dev.msfjarvis.claw.android"
|
||||
defaultConfig.applicationId = "dev.msfjarvis.claw.android"
|
||||
buildFeatures.compose = true
|
||||
composeOptions {
|
||||
useLiveLiterals = false
|
||||
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
|
||||
}
|
||||
// kotlinx.serialization 1.6.0-RC regression
|
||||
packagingOptions.resources.pickFirsts += "**/*.bin"
|
||||
}
|
||||
|
||||
baselineProfile {
|
||||
mergeIntoMain = true
|
||||
saveInSrc = true
|
||||
from(projects.benchmark.dependencyProject)
|
||||
}
|
||||
|
||||
licensee {
|
||||
allow("Apache-2.0")
|
||||
allow("MIT")
|
||||
ignoreDependencies("org.commonmark") { because("Commonmark is BSD licensed") }
|
||||
allowUrl("https://jsoup.org/license") { because("Jsoup is MIT licensed") }
|
||||
allowUrl("https://opensource.org/licenses/isc-license.txt") {
|
||||
because("kotlin-result is ISC licensed")
|
||||
}
|
||||
}
|
||||
|
||||
moduleGraphAssert {
|
||||
assertOnAnyBuild = true
|
||||
maxHeight = 4
|
||||
restricted = arrayOf(":core -X> :.*")
|
||||
}
|
||||
|
||||
whetstone {
|
||||
@@ -24,47 +64,41 @@ whetstone {
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "dev.msfjarvis.claw.android"
|
||||
defaultConfig { applicationId = "dev.msfjarvis.claw.android" }
|
||||
buildFeatures { compose = true }
|
||||
composeOptions {
|
||||
useLiveLiterals = false
|
||||
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
|
||||
}
|
||||
buildTypes {
|
||||
create("benchmark") {
|
||||
signingConfig = signingConfigs["debug"]
|
||||
matchingFallbacks += "release"
|
||||
isDebuggable = false
|
||||
proguardFile("benchmark-rules.pro")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
kapt(libs.dagger.compiler)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.dagger)
|
||||
implementation(projects.api)
|
||||
implementation(projects.common)
|
||||
implementation(projects.core)
|
||||
implementation(projects.coroutineUtils)
|
||||
implementation(projects.database)
|
||||
implementation(projects.metadataExtractor)
|
||||
implementation(projects.model)
|
||||
implementation(libs.accompanist.sysuicontroller)
|
||||
implementation(platform(libs.okhttp.bom))
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.material)
|
||||
implementation(libs.androidx.compose.glance)
|
||||
implementation(libs.androidx.compose.glance.m3)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material3.window.size)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.androidx.lifecycle.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.paging.compose)
|
||||
implementation(libs.androidx.profileinstaller)
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(libs.androidx.work.runtime)
|
||||
implementation(libs.coil)
|
||||
implementation(libs.copydown)
|
||||
implementation(libs.dagger)
|
||||
implementation(libs.jsoup)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.material3.pulltorefresh)
|
||||
implementation(libs.napier)
|
||||
implementation(libs.sqldelight.extensions.coroutines)
|
||||
implementation(libs.swipe)
|
||||
implementation(libs.unfurl)
|
||||
implementation(projects.api)
|
||||
implementation(projects.common)
|
||||
implementation(projects.core)
|
||||
implementation(projects.database.impl)
|
||||
implementation(projects.model)
|
||||
|
||||
kapt(libs.dagger.compiler)
|
||||
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
addTestDependencies(project)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues format="6" by="lint 7.4.0-alpha09" type="baseline" client="gradle" dependencies="false" name="AGP (7.4.0-alpha09)" variant="all" version="7.4.0-alpha09">
|
||||
<issues format="6" by="lint 8.3.0-alpha02" type="baseline" client="gradle" dependencies="true" name="AGP (8.3.0-alpha02)" variant="all" version="8.3.0-alpha02">
|
||||
|
||||
</issues>
|
||||
|
||||
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -1,22 +1,19 @@
|
||||
-allowaccessmodification
|
||||
-dontpreverify
|
||||
-dontusemixedcaseclassnames
|
||||
-dontskipnonpubliclibraryclasses
|
||||
-verbose
|
||||
|
||||
-keepattributes *Annotation*
|
||||
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
native <methods>;
|
||||
}
|
||||
|
||||
-keepclassmembers enum * {
|
||||
public static **[] values();
|
||||
public static ** valueOf(java.lang.String);
|
||||
public static **[] values();
|
||||
public static ** valueOf(java.lang.String);
|
||||
}
|
||||
|
||||
-keepclassmembers class * implements android.os.Parcelable {
|
||||
public static final ** CREATOR;
|
||||
public static final ** CREATOR;
|
||||
}
|
||||
|
||||
-keep class androidx.annotation.Keep
|
||||
@@ -24,13 +21,13 @@
|
||||
-keep @androidx.annotation.Keep class * {*;}
|
||||
|
||||
-keepclasseswithmembers class * {
|
||||
@androidx.annotation.Keep <methods>;
|
||||
@androidx.annotation.Keep <methods>;
|
||||
}
|
||||
|
||||
-keepclasseswithmembers class * {
|
||||
@androidx.annotation.Keep <fields>;
|
||||
@androidx.annotation.Keep <fields>;
|
||||
}
|
||||
|
||||
-keepclasseswithmembers class * {
|
||||
@androidx.annotation.Keep <init>(...);
|
||||
@androidx.annotation.Keep <init>(...);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,22 @@
|
||||
-dontobfuscate
|
||||
-keepattributes SourceFile, LineNumberTable
|
||||
|
||||
# From https://github.com/square/retrofit/blob/master/retrofit/src/main/resources/META-INF/proguard/retrofit2.pro
|
||||
|
||||
# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy
|
||||
# and replaces all potential values with null. Explicitly keeping the interfaces prevents this.
|
||||
-if interface * { @retrofit2.http.* <methods>; }
|
||||
-keep,allowobfuscation interface <1>
|
||||
|
||||
# Keep inherited services.
|
||||
-if interface * { @retrofit2.http.* <methods>; }
|
||||
-keep,allowobfuscation interface * extends <1>
|
||||
|
||||
# With R8 full mode generic signatures are stripped for classes that are not
|
||||
# kept. Suspend functions are wrapped in continuations where the type argument
|
||||
# is used.
|
||||
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
|
||||
|
||||
# R8 full mode strips generic signatures from return types if not kept.
|
||||
-if interface * { @retrofit2.http.* public *** *(...); }
|
||||
-keep,allowoptimization,allowshrinking,allowobfuscation class <3>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!--
|
||||
~ Copyright © 2021-2023 Harsh Shandilya.
|
||||
~ Use of this source code is governed by an MIT-style
|
||||
~ license that can be found in the LICENSE file or at
|
||||
~ https://opensource.org/licenses/MIT.
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">Claw β</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!--
|
||||
~ Copyright © 2021-2023 Harsh Shandilya.
|
||||
~ Use of this source code is governed by an MIT-style
|
||||
~ license that can be found in the LICENSE file or at
|
||||
~ https://opensource.org/licenses/MIT.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
@@ -6,13 +11,13 @@
|
||||
<application
|
||||
android:name=".ClawApplication"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:fullBackupContent="@xml/full_backup_content"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Claw.SplashScreen"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
tools:targetApi="tiramisu">
|
||||
<profileable
|
||||
android:shell="true"
|
||||
@@ -25,19 +30,6 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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:scheme="https" />
|
||||
<data android:ssp="//lobste.rs" />
|
||||
|
||||
<data android:host="lobste.rs" />
|
||||
<data android:path="/" />
|
||||
<data android:scheme="https" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
@@ -163,5 +155,23 @@
|
||||
<action android:name="androidx.profileinstaller.action.BENCHMARK_OPERATION" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<receiver android:name=".glance.WidgetReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/saved_posts_widget_info" />
|
||||
</receiver>
|
||||
<!-- Required: set your sentry.io project identifier (DSN) -->
|
||||
<meta-data android:name="io.sentry.dsn" android:value="${sentryDsn}" />
|
||||
<!-- enable automatic breadcrumbs for user interactions (clicks, swipes, scrolls) -->
|
||||
<meta-data android:name="io.sentry.traces.user-interaction.enable" android:value="${enableSentry}" />
|
||||
<meta-data android:name="io.sentry.breadcrumbs.user-interaction" android:value="${enableSentry}" />
|
||||
<!-- enable screenshot for crashes -->
|
||||
<meta-data android:name="io.sentry.attach-screenshot" android:value="${enableSentry}" />
|
||||
<!-- enable view hierarchy for crashes -->
|
||||
<meta-data android:name="io.sentry.attach-view-hierarchy" android:value="${enableSentry}" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © 2021-2022 Harsh Shandilya.
|
||||
* Copyright © 2021-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © 2021-2022 Harsh Shandilya.
|
||||
* Copyright © 2021-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
@@ -11,11 +11,16 @@ import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.deliveryhero.whetstone.Whetstone
|
||||
import com.deliveryhero.whetstone.activity.ContributesActivityInjector
|
||||
import dev.msfjarvis.claw.android.ui.LobstersApp
|
||||
import dev.msfjarvis.claw.common.comments.HTMLConverter
|
||||
import dev.msfjarvis.claw.common.theme.LobstersTheme
|
||||
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -26,16 +31,25 @@ class MainActivity : ComponentActivity() {
|
||||
@Inject lateinit var htmlConverter: HTMLConverter
|
||||
private var webUri: String? = null
|
||||
|
||||
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
installSplashScreen()
|
||||
Whetstone.inject(this)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
LobstersApp(
|
||||
urlLauncher = urlLauncher,
|
||||
htmlConverter = htmlConverter,
|
||||
setWebUri = { url -> webUri = url },
|
||||
)
|
||||
val windowSizeClass = calculateWindowSizeClass(this)
|
||||
LobstersTheme(
|
||||
dynamicColor = true,
|
||||
providedValues = arrayOf(LocalUriHandler provides urlLauncher),
|
||||
) {
|
||||
LobstersApp(
|
||||
urlLauncher = urlLauncher,
|
||||
htmlConverter = htmlConverter,
|
||||
windowSizeClass = windowSizeClass,
|
||||
setWebUri = { url -> webUri = url },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,4 +63,8 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val NAVIGATION_KEY = "postId"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright © 2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.glance
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.appWidgetBackground
|
||||
import androidx.glance.appwidget.lazy.LazyColumn
|
||||
import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.material3.ColorProviders
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextStyle
|
||||
import dev.msfjarvis.claw.common.theme.DarkThemeColors
|
||||
import dev.msfjarvis.claw.common.theme.LightThemeColors
|
||||
import dev.msfjarvis.claw.database.local.SavedPost
|
||||
|
||||
class SavedPostsWidget(
|
||||
private val posts: List<SavedPost>,
|
||||
) : GlanceAppWidget() {
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
provideContent {
|
||||
GlanceTheme(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) GlanceTheme.colors
|
||||
else ColorProviders(light = LightThemeColors, dark = DarkThemeColors)
|
||||
) {
|
||||
WidgetHost()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WidgetHost() {
|
||||
LazyColumn(
|
||||
modifier =
|
||||
GlanceModifier.fillMaxSize()
|
||||
.background(GlanceTheme.colors.background)
|
||||
.appWidgetBackground(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
item {
|
||||
val style = MaterialTheme.typography.titleLarge
|
||||
Text(
|
||||
text = "Saved posts",
|
||||
style =
|
||||
TextStyle(
|
||||
color = GlanceTheme.colors.onBackground,
|
||||
fontSize = style.fontSize,
|
||||
fontWeight = style.fontWeight.toGlance(),
|
||||
fontStyle = style.fontStyle.toGlance(),
|
||||
),
|
||||
modifier = GlanceModifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
items(posts) { post -> WidgetListEntry(post = post) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright © 2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.glance
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.glance.GlanceComposable
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.GlanceTheme
|
||||
import androidx.glance.action.ActionParameters.Key
|
||||
import androidx.glance.action.actionParametersOf
|
||||
import androidx.glance.action.actionStartActivity
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.appwidget.cornerRadius
|
||||
import androidx.glance.background
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.wrapContentWidth
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import dev.msfjarvis.claw.android.MainActivity
|
||||
import dev.msfjarvis.claw.android.MainActivity.Companion.NAVIGATION_KEY
|
||||
import dev.msfjarvis.claw.database.local.SavedPost
|
||||
|
||||
private val destinationKey = Key<String>(NAVIGATION_KEY)
|
||||
|
||||
@Composable
|
||||
@GlanceComposable
|
||||
fun WidgetListEntry(
|
||||
post: SavedPost,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
) {
|
||||
val titleStyle = MaterialTheme.typography.titleMedium
|
||||
val subtitleStyle = MaterialTheme.typography.labelLarge
|
||||
val commentsAction =
|
||||
actionStartActivity<MainActivity>(actionParametersOf(destinationKey to post.shortId))
|
||||
val postAction =
|
||||
if (post.url.isNotEmpty()) actionStartActivity(Intent(Intent.ACTION_VIEW, Uri.parse(post.url)))
|
||||
else commentsAction
|
||||
Box(modifier.padding(8.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier =
|
||||
GlanceModifier.fillMaxWidth()
|
||||
.background(GlanceTheme.colors.surfaceVariant)
|
||||
.cornerRadius(16.dp)
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = post.title,
|
||||
modifier = GlanceModifier.defaultWeight().padding(start = 4.dp).clickable(postAction),
|
||||
style =
|
||||
TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = titleStyle.fontSize,
|
||||
fontWeight = titleStyle.fontWeight.toGlance(),
|
||||
fontStyle = titleStyle.fontStyle.toGlance(),
|
||||
)
|
||||
)
|
||||
post.commentCount?.let { count ->
|
||||
Text(
|
||||
text = "$count comments",
|
||||
modifier =
|
||||
GlanceModifier.wrapContentWidth().padding(end = 4.dp).clickable(commentsAction),
|
||||
style =
|
||||
TextStyle(
|
||||
color = GlanceTheme.colors.onSurfaceVariant,
|
||||
fontSize = subtitleStyle.fontSize,
|
||||
fontWeight = subtitleStyle.fontWeight.toGlance(),
|
||||
fontStyle = subtitleStyle.fontStyle.toGlance(),
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright © 2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.glance
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import com.deliveryhero.whetstone.Whetstone
|
||||
import com.deliveryhero.whetstone.broadcastreceiver.ContributesBroadcastReceiverInjector
|
||||
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
@ContributesBroadcastReceiverInjector
|
||||
class WidgetReceiver : GlanceAppWidgetReceiver() {
|
||||
|
||||
@Inject lateinit var viewModel: ClawViewModel
|
||||
|
||||
override val glanceAppWidget: GlanceAppWidget
|
||||
get() = SavedPostsWidget(runBlocking { viewModel.savedPosts.first().take(50) })
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Whetstone.inject(this, context)
|
||||
super.onReceive(context, intent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright © 2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.glance
|
||||
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
||||
fun FontWeight?.toGlance(): androidx.glance.text.FontWeight? {
|
||||
return when (this) {
|
||||
FontWeight.Normal -> androidx.glance.text.FontWeight.Normal
|
||||
FontWeight.Medium -> androidx.glance.text.FontWeight.Medium
|
||||
FontWeight.Bold -> androidx.glance.text.FontWeight.Bold
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun FontStyle?.toGlance(): androidx.glance.text.FontStyle? {
|
||||
return when (this) {
|
||||
FontStyle.Normal -> androidx.glance.text.FontStyle.Normal
|
||||
FontStyle.Italic -> androidx.glance.text.FontStyle.Italic
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © 2021-2022 Harsh Shandilya.
|
||||
* Copyright © 2021-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
@@ -16,5 +16,5 @@ import dev.msfjarvis.claw.common.comments.HTMLConverter
|
||||
@Module
|
||||
@ContributesTo(ApplicationScope::class)
|
||||
interface HTMLConverterModule {
|
||||
@Binds fun HTMLConverterImpl.bind(): HTMLConverter
|
||||
@Binds fun bindHTMLConverter(impl: HTMLConverterImpl): HTMLConverter
|
||||
}
|
||||
|
||||
@@ -1,25 +1,44 @@
|
||||
/*
|
||||
* Copyright © 2021-2022 Harsh Shandilya.
|
||||
* Copyright © 2021-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.injection
|
||||
|
||||
import com.chimbori.crux.Crux
|
||||
import com.deliveryhero.whetstone.app.ApplicationScope
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import io.github.aakira.napier.Napier
|
||||
import me.saket.unfurl.UnfurlLogger
|
||||
import me.saket.unfurl.Unfurler
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
@Module
|
||||
@ContributesTo(ApplicationScope::class)
|
||||
object MetadataExtractorModule {
|
||||
@Provides
|
||||
fun provideCrux(
|
||||
fun provideUnfurlLogger(): UnfurlLogger {
|
||||
return object : UnfurlLogger {
|
||||
override fun log(message: String) {
|
||||
Napier.d { message }
|
||||
}
|
||||
|
||||
override fun log(e: Throwable, message: String) {
|
||||
Napier.e(e) { message }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideUnfurler(
|
||||
okHttpClient: OkHttpClient,
|
||||
): Crux {
|
||||
return Crux(okHttpClient = okHttpClient)
|
||||
logger: UnfurlLogger,
|
||||
): Unfurler {
|
||||
return Unfurler(
|
||||
httpClient = okHttpClient,
|
||||
logger = logger,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © 2021-2022 Harsh Shandilya.
|
||||
* Copyright © 2021-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
@@ -7,9 +7,9 @@
|
||||
package dev.msfjarvis.claw.android.injection
|
||||
|
||||
import android.content.Context
|
||||
import com.deliveryhero.whetstone.ForScope
|
||||
import com.deliveryhero.whetstone.activity.ActivityScope
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import com.squareup.anvil.annotations.optional.ForScope
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2022 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.injection
|
||||
|
||||
import javax.inject.Qualifier
|
||||
|
||||
@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class DatabaseDispatcher
|
||||
|
||||
@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class MainDispatcher
|
||||
|
||||
@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class IODispatcher
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © 2021-2022 Harsh Shandilya.
|
||||
* Copyright © 2021-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
@@ -13,7 +13,7 @@ import com.slack.eithernet.ApiResult.Success
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dev.msfjarvis.claw.android.injection.IODispatcher
|
||||
import dev.msfjarvis.claw.core.injection.IODispatcher
|
||||
import dev.msfjarvis.claw.model.LobstersPost
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright © 2021-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.paging
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.paging.PagingState
|
||||
import com.slack.eithernet.ApiResult
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.PAGE_SIZE
|
||||
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.STARTING_PAGE_INDEX
|
||||
import dev.msfjarvis.claw.core.injection.IODispatcher
|
||||
import dev.msfjarvis.claw.model.LobstersPost
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Duplicate of [LobstersPagingSource] with an additional optimization to not continue loading
|
||||
* further pages if nothing is found in the current one. This is required to prevent the app from
|
||||
* hammering the search endpoint with up to 70 consecutive requests for the search results of a
|
||||
* blank query.
|
||||
*/
|
||||
class SearchPagingSource
|
||||
@AssistedInject
|
||||
constructor(
|
||||
@Assisted private val remoteFetcher: RemoteFetcher<LobstersPost>,
|
||||
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) : PagingSource<Int, LobstersPost>() {
|
||||
override fun getRefreshKey(state: PagingState<Int, LobstersPost>): Int? {
|
||||
return state.anchorPosition?.let { anchorPosition ->
|
||||
(anchorPosition / PAGE_SIZE).coerceAtLeast(STARTING_PAGE_INDEX)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, LobstersPost> {
|
||||
val page = params.key ?: STARTING_PAGE_INDEX
|
||||
return when (val result = withContext(ioDispatcher) { remoteFetcher.getItemsAtPage(page) }) {
|
||||
is ApiResult.Success -> {
|
||||
// Optimization: prevent fetching more pages if we found no items, this means
|
||||
// there is no active search query.
|
||||
val nextKey = if (result.value.isEmpty()) null else page + 1
|
||||
LoadResult.Page(
|
||||
itemsBefore = (page - 1) * PAGE_SIZE,
|
||||
data = result.value,
|
||||
prevKey = if (page == STARTING_PAGE_INDEX) null else page - 1,
|
||||
nextKey = nextKey,
|
||||
)
|
||||
}
|
||||
is ApiResult.Failure.NetworkFailure -> LoadResult.Error(result.error)
|
||||
is ApiResult.Failure.UnknownFailure -> LoadResult.Error(result.error)
|
||||
is ApiResult.Failure.HttpFailure,
|
||||
is ApiResult.Failure.ApiFailure ->
|
||||
LoadResult.Error(IOException("API returned an invalid response"))
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(remoteFetcher: RemoteFetcher<LobstersPost>): SearchPagingSource
|
||||
}
|
||||
}
|
||||
@@ -6,25 +6,40 @@
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.ImportExport
|
||||
import androidx.compose.material.icons.filled.NewReleases
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Whatshot
|
||||
import androidx.compose.material.icons.outlined.FavoriteBorder
|
||||
import androidx.compose.material.icons.outlined.NewReleases
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.icons.outlined.Whatshot
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.testTagsAsResourceId
|
||||
@@ -38,43 +53,45 @@ import androidx.navigation.navArgument
|
||||
import androidx.navigation.navDeepLink
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.deliveryhero.whetstone.compose.injectedViewModel
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import dev.msfjarvis.claw.android.MainActivity
|
||||
import dev.msfjarvis.claw.android.R
|
||||
import dev.msfjarvis.claw.android.ui.datatransfer.DataTransferScreen
|
||||
import dev.msfjarvis.claw.android.ui.decorations.ClawAppBar
|
||||
import dev.msfjarvis.claw.android.ui.decorations.ClawNavigationBar
|
||||
import dev.msfjarvis.claw.android.ui.decorations.ClawNavigationRail
|
||||
import dev.msfjarvis.claw.android.ui.decorations.NavigationItem
|
||||
import dev.msfjarvis.claw.android.ui.lists.DatabasePosts
|
||||
import dev.msfjarvis.claw.android.ui.lists.NetworkPosts
|
||||
import dev.msfjarvis.claw.android.ui.lists.SearchList
|
||||
import dev.msfjarvis.claw.android.ui.navigation.ClawNavigationType
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Destinations
|
||||
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
|
||||
import dev.msfjarvis.claw.api.LobstersApi
|
||||
import dev.msfjarvis.claw.common.comments.CommentsPage
|
||||
import dev.msfjarvis.claw.common.comments.HTMLConverter
|
||||
import dev.msfjarvis.claw.common.res.ClawIcons
|
||||
import dev.msfjarvis.claw.common.theme.LobstersTheme
|
||||
import dev.msfjarvis.claw.common.ui.decorations.ClawAppBar
|
||||
import dev.msfjarvis.claw.common.ui.surfaceColorAtNavigationBarElevation
|
||||
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
|
||||
import dev.msfjarvis.claw.common.user.UserProfile
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(
|
||||
ExperimentalComposeUiApi::class,
|
||||
ExperimentalMaterial3Api::class,
|
||||
)
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun LobstersApp(
|
||||
urlLauncher: UrlLauncher,
|
||||
htmlConverter: HTMLConverter,
|
||||
windowSizeClass: WindowSizeClass,
|
||||
setWebUri: (String?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ClawViewModel = injectedViewModel(),
|
||||
) {
|
||||
val systemUiController = rememberSystemUiController()
|
||||
val hottestListState = rememberLazyListState()
|
||||
val newestListState = rememberLazyListState()
|
||||
val savedListState = rememberLazyListState()
|
||||
val searchListState = rememberLazyListState()
|
||||
val navController = rememberNavController()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val postActions = rememberPostActions(urlLauncher, navController, viewModel)
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = backStackEntry?.destination?.route
|
||||
@@ -82,67 +99,69 @@ fun LobstersApp(
|
||||
|
||||
val hottestPosts = viewModel.hottestPosts.collectAsLazyPagingItems()
|
||||
val newestPosts = viewModel.newestPosts.collectAsLazyPagingItems()
|
||||
val savedPosts by viewModel.savedPosts.collectAsState(emptyMap())
|
||||
val savedPosts by viewModel.savedPostsByMonth.collectAsState(persistentMapOf())
|
||||
|
||||
LobstersTheme(
|
||||
dynamicColor = true,
|
||||
providedValues = arrayOf(LocalUriHandler provides urlLauncher),
|
||||
) {
|
||||
val currentUiMode = LocalConfiguration.current.uiMode
|
||||
val systemBarsColor = MaterialTheme.colorScheme.surfaceColorAtNavigationBarElevation()
|
||||
val backgroundColor = MaterialTheme.colorScheme.background
|
||||
val navItems =
|
||||
listOf(
|
||||
NavigationItem(
|
||||
label = "Hottest",
|
||||
route = Destinations.Hottest.route,
|
||||
icon = ClawIcons.Flame,
|
||||
selectedIcon = ClawIcons.FlameFilled,
|
||||
) {
|
||||
coroutineScope.launch { hottestListState.animateScrollToItem(index = 0) }
|
||||
},
|
||||
NavigationItem(
|
||||
label = "Newest",
|
||||
route = Destinations.Newest.route,
|
||||
icon = ClawIcons.New,
|
||||
selectedIcon = ClawIcons.NewFilled,
|
||||
) {
|
||||
coroutineScope.launch { newestListState.animateScrollToItem(index = 0) }
|
||||
},
|
||||
NavigationItem(
|
||||
label = "Saved",
|
||||
route = Destinations.Saved.route,
|
||||
icon = ClawIcons.HeartBorder,
|
||||
selectedIcon = ClawIcons.Heart,
|
||||
) {
|
||||
coroutineScope.launch { savedListState.animateScrollToItem(index = 0) }
|
||||
},
|
||||
val navigationType = ClawNavigationType.fromSize(windowSizeClass.widthSizeClass)
|
||||
|
||||
val postIdOverride = context.getActivity()?.intent?.extras?.getString(MainActivity.NAVIGATION_KEY)
|
||||
LaunchedEffect(false) {
|
||||
if (postIdOverride != null) {
|
||||
navController.navigate(
|
||||
Destinations.Comments.route.replace(Destinations.Comments.placeholder, postIdOverride)
|
||||
)
|
||||
|
||||
SideEffect { systemUiController.setStatusBarColor(color = systemBarsColor) }
|
||||
|
||||
// Track UI mode as a key to force a navbar color update when dark theme is toggled
|
||||
LaunchedEffect(currentDestination, currentUiMode) {
|
||||
val color =
|
||||
if (navItems.none { item -> item.route == currentDestination }) {
|
||||
backgroundColor
|
||||
} else {
|
||||
systemBarsColor
|
||||
}
|
||||
systemUiController.setNavigationBarColor(color = color)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
val navItems =
|
||||
persistentListOf(
|
||||
NavigationItem(
|
||||
label = "Hottest",
|
||||
route = Destinations.Hottest.route,
|
||||
icon = Icons.Outlined.Whatshot,
|
||||
selectedIcon = Icons.Filled.Whatshot,
|
||||
) {
|
||||
coroutineScope.launch { hottestListState.animateScrollToItem(index = 0) }
|
||||
},
|
||||
NavigationItem(
|
||||
label = "Newest",
|
||||
route = Destinations.Newest.route,
|
||||
icon = Icons.Outlined.NewReleases,
|
||||
selectedIcon = Icons.Filled.NewReleases,
|
||||
) {
|
||||
coroutineScope.launch { newestListState.animateScrollToItem(index = 0) }
|
||||
},
|
||||
NavigationItem(
|
||||
label = "Saved",
|
||||
route = Destinations.Saved.route,
|
||||
icon = Icons.Outlined.FavoriteBorder,
|
||||
selectedIcon = Icons.Filled.Favorite,
|
||||
) {
|
||||
coroutineScope.launch { savedListState.animateScrollToItem(index = 0) }
|
||||
},
|
||||
NavigationItem(
|
||||
label = "Search",
|
||||
route = Destinations.Search.route,
|
||||
icon = Icons.Outlined.Search,
|
||||
selectedIcon = Icons.Filled.Search,
|
||||
) {
|
||||
coroutineScope.launch { searchListState.animateScrollToItem(index = 0) }
|
||||
},
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (currentDestination != Destinations.Search.route) {
|
||||
ClawAppBar(
|
||||
backgroundColor = systemBarsColor,
|
||||
navigationIcon = {
|
||||
if (navItems.none { it.route == currentDestination }) {
|
||||
if (
|
||||
navController.previousBackStackEntry != null &&
|
||||
navItems.none { it.route == currentDestination }
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { if (!navController.popBackStack()) context.getActivity()?.finish() }
|
||||
onClick = { if (!navController.popBackStack()) context.getActivity()?.finish() },
|
||||
) {
|
||||
Icon(
|
||||
painter = ClawIcons.ArrowBack,
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Go back to previous screen",
|
||||
)
|
||||
}
|
||||
@@ -153,48 +172,69 @@ fun LobstersApp(
|
||||
Text(text = stringResource(R.string.app_name), fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (navItems.any { it.route == currentDestination }) {
|
||||
IconButton(onClick = { navController.navigate(Destinations.DataTransfer.route) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ImportExport,
|
||||
contentDescription = "Data transfer options",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
AnimatedVisibility(visible = navigationType == ClawNavigationType.BOTTOM_NAVIGATION) {
|
||||
ClawNavigationBar(
|
||||
navController = navController,
|
||||
items = navItems,
|
||||
isVisible = navItems.any { it.route == currentDestination },
|
||||
)
|
||||
},
|
||||
modifier = modifier.semantics { testTagsAsResourceId = true },
|
||||
) { paddingValues ->
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
modifier = modifier.semantics { testTagsAsResourceId = true },
|
||||
) { paddingValues ->
|
||||
Row(modifier = Modifier.padding(paddingValues)) {
|
||||
AnimatedVisibility(visible = navigationType == ClawNavigationType.NAVIGATION_RAIL) {
|
||||
ClawNavigationRail(
|
||||
navController = navController,
|
||||
items = navItems,
|
||||
isVisible = navItems.any { it.route == currentDestination },
|
||||
)
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Destinations.startDestination.route,
|
||||
modifier = Modifier.padding(paddingValues),
|
||||
// Make animations 2x faster than default specs
|
||||
enterTransition = { fadeIn(animationSpec = tween(350)) },
|
||||
exitTransition = { fadeOut(animationSpec = tween(350)) },
|
||||
) {
|
||||
val uri = LobstersApi.BASE_URL
|
||||
composable(
|
||||
route = Destinations.Hottest.route,
|
||||
deepLinks =
|
||||
listOf(navDeepLink { uriPattern = uri }, navDeepLink { uriPattern = "$uri/" }),
|
||||
) {
|
||||
composable(route = Destinations.Hottest.route) {
|
||||
setWebUri("https://lobste.rs/")
|
||||
NetworkPosts(
|
||||
lazyPagingItems = hottestPosts,
|
||||
listState = hottestListState,
|
||||
isPostSaved = viewModel::isPostSaved,
|
||||
isPostRead = viewModel::isPostRead,
|
||||
postActions = postActions,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Destinations.Newest.route,
|
||||
) {
|
||||
composable(route = Destinations.Newest.route) {
|
||||
setWebUri("https://lobste.rs/")
|
||||
NetworkPosts(
|
||||
lazyPagingItems = newestPosts,
|
||||
listState = newestListState,
|
||||
isPostSaved = viewModel::isPostSaved,
|
||||
isPostRead = viewModel::isPostRead,
|
||||
postActions = postActions,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Saved.route) {
|
||||
composable(route = Destinations.Saved.route) {
|
||||
setWebUri(null)
|
||||
DatabasePosts(
|
||||
items = savedPosts,
|
||||
@@ -202,6 +242,17 @@ fun LobstersApp(
|
||||
postActions = postActions,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Search.route) {
|
||||
setWebUri("https://lobste.rs/search")
|
||||
SearchList(
|
||||
items = viewModel.searchResults,
|
||||
listState = searchListState,
|
||||
isPostSaved = viewModel::isPostSaved,
|
||||
postActions = postActions,
|
||||
searchQuery = viewModel.searchQuery,
|
||||
setSearchQuery = { query -> viewModel.searchQuery = query },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Destinations.Comments.route,
|
||||
arguments = listOf(navArgument("postId") { type = NavType.StringType }),
|
||||
@@ -211,7 +262,10 @@ fun LobstersApp(
|
||||
navDeepLink { uriPattern = "$uri/s/${Destinations.Comments.placeholder}" },
|
||||
),
|
||||
) { backStackEntry ->
|
||||
val postId = requireNotNull(backStackEntry.arguments?.getString("postId"))
|
||||
val postId =
|
||||
requireNotNull(backStackEntry.arguments?.getString("postId")) {
|
||||
"Navigating to ${Destinations.Comments.route} without necessary 'postId' argument"
|
||||
}
|
||||
setWebUri("https://lobste.rs/s/$postId")
|
||||
CommentsPage(
|
||||
postId = postId,
|
||||
@@ -227,12 +281,24 @@ fun LobstersApp(
|
||||
deepLinks =
|
||||
listOf(navDeepLink { uriPattern = "$uri/u/${Destinations.User.placeholder}" }),
|
||||
) { backStackEntry ->
|
||||
val username = requireNotNull(backStackEntry.arguments?.getString("username"))
|
||||
val username =
|
||||
requireNotNull(backStackEntry.arguments?.getString("username")) {
|
||||
"Navigating to ${Destinations.User.route} without necessary 'username' argument"
|
||||
}
|
||||
setWebUri("https://lobste.rs/u/$username")
|
||||
UserProfile(
|
||||
username = username,
|
||||
getProfile = viewModel::getUserProfile,
|
||||
)
|
||||
}
|
||||
composable(route = Destinations.DataTransfer.route) {
|
||||
DataTransferScreen(
|
||||
context = context,
|
||||
importPosts = viewModel::importPosts,
|
||||
exportPosts = viewModel::exportPosts,
|
||||
snackbarHostState = snackbarHostState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright © 2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.datatransfer
|
||||
|
||||
import android.content.Context
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
|
||||
import androidx.activity.result.contract.ActivityResultContracts.GetContent
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Upload
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val MIME_TYPE = "application/json"
|
||||
|
||||
@Composable
|
||||
fun DataTransferScreen(
|
||||
context: Context,
|
||||
importPosts: suspend (InputStream) -> Unit,
|
||||
exportPosts: suspend (OutputStream) -> Unit,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
Column(modifier = modifier) {
|
||||
ImportOption(context, coroutineScope, importPosts, snackbarHostState)
|
||||
ExportOption(context, coroutineScope, exportPosts, snackbarHostState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImportOption(
|
||||
context: Context,
|
||||
coroutineScope: CoroutineScope,
|
||||
importPosts: suspend (InputStream) -> Unit,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
) {
|
||||
val importAction =
|
||||
rememberLauncherForActivityResult(GetContent()) { uri ->
|
||||
if (uri == null) {
|
||||
coroutineScope.launch { snackbarHostState.showSnackbarDismissing("No file selected") }
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
coroutineScope.launch {
|
||||
context.contentResolver.openInputStream(uri)?.use { stream ->
|
||||
importPosts(stream)
|
||||
snackbarHostState.showSnackbarDismissing("Successfully imported posts")
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsActionItem(
|
||||
title = "Import saved posts",
|
||||
description = "Import saved posts from a previously generated export",
|
||||
icon = Icons.Filled.Download,
|
||||
) {
|
||||
importAction.launch(MIME_TYPE)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExportOption(
|
||||
context: Context,
|
||||
coroutineScope: CoroutineScope,
|
||||
exportPosts: suspend (OutputStream) -> Unit,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
) {
|
||||
val exportAction =
|
||||
rememberLauncherForActivityResult(CreateDocument(MIME_TYPE)) { uri ->
|
||||
if (uri == null) {
|
||||
coroutineScope.launch { snackbarHostState.showSnackbarDismissing("No file selected") }
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
coroutineScope.launch {
|
||||
context.contentResolver.openOutputStream(uri)?.use { stream ->
|
||||
exportPosts(stream)
|
||||
snackbarHostState.showSnackbarDismissing("Successfully exported posts")
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsActionItem(
|
||||
title = "Export posts to file",
|
||||
description = "Write all saved posts into a JSON file that can be imported at a later date",
|
||||
icon = Icons.Filled.Upload,
|
||||
) {
|
||||
exportAction.launch("claw-export.json")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsActionItem(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
description: String? = null,
|
||||
icon: ImageVector? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = { Text(title) },
|
||||
supportingContent = { description?.let { Text(it) } },
|
||||
leadingContent = {
|
||||
icon?.let {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.height(32.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = modifier.clickable { onClick?.invoke() },
|
||||
)
|
||||
}
|
||||
|
||||
/** Shows a Snackbar but dismisses any existing ones first. */
|
||||
private suspend fun SnackbarHostState.showSnackbarDismissing(
|
||||
text: String,
|
||||
) {
|
||||
currentSnackbarData?.dismiss()
|
||||
showSnackbar(text)
|
||||
}
|
||||
@@ -4,29 +4,25 @@
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.common.ui.decorations
|
||||
package dev.msfjarvis.claw.android.ui.decorations
|
||||
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.msfjarvis.claw.common.theme.LobstersTheme
|
||||
import dev.msfjarvis.claw.common.ui.preview.DevicePreviews
|
||||
import dev.msfjarvis.claw.common.ui.preview.ThemePreviews
|
||||
import dev.msfjarvis.claw.common.ui.surfaceColorAtNavigationBarElevation
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun ClawAppBar(
|
||||
backgroundColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
navigationIcon: @Composable () -> Unit = {},
|
||||
title: @Composable () -> Unit = {},
|
||||
@@ -35,7 +31,7 @@ fun ClawAppBar(
|
||||
TopAppBar(
|
||||
title = title,
|
||||
modifier = modifier.shadow(8.dp),
|
||||
colors = TopAppBarDefaults.smallTopAppBarColors(containerColor = backgroundColor),
|
||||
colors = TopAppBarDefaults.topAppBarColors(),
|
||||
navigationIcon = navigationIcon,
|
||||
actions = actions,
|
||||
)
|
||||
@@ -47,7 +43,6 @@ fun ClawAppBar(
|
||||
fun ClawAppBarPreview() {
|
||||
LobstersTheme {
|
||||
ClawAppBar(
|
||||
backgroundColor = MaterialTheme.colorScheme.surfaceColorAtNavigationBarElevation(),
|
||||
title = { Text("Claw", fontWeight = FontWeight.Bold) },
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © 2022 Harsh Shandilya.
|
||||
* Copyright © 2022-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
@@ -19,17 +19,18 @@ import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.navigation.NavController
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Destinations
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
private const val AnimationDuration = 100
|
||||
const val AnimationDuration = 100
|
||||
|
||||
@Composable
|
||||
fun ClawNavigationBar(
|
||||
navController: NavController,
|
||||
items: List<NavigationItem>,
|
||||
items: ImmutableList<NavigationItem>,
|
||||
isVisible: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -54,10 +55,10 @@ fun ClawNavigationBar(
|
||||
val isCurrentDestination = navController.currentDestination?.route == navItem.route
|
||||
NavigationBarItem(
|
||||
icon = {
|
||||
Crossfade(isCurrentDestination) {
|
||||
Crossfade(isCurrentDestination, label = "nav-label") {
|
||||
Icon(
|
||||
painter = if (it) navItem.selectedIcon else navItem.icon,
|
||||
contentDescription = navItem.label.uppercase()
|
||||
imageVector = if (it) navItem.selectedIcon else navItem.icon,
|
||||
contentDescription = navItem.label.replaceFirstChar(Char::uppercase),
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -66,13 +67,13 @@ fun ClawNavigationBar(
|
||||
onClick = {
|
||||
if (isCurrentDestination) {
|
||||
navItem.listStateResetCallback()
|
||||
return@NavigationBarItem
|
||||
}
|
||||
navController.graph.startDestinationRoute?.let { startDestination ->
|
||||
navController.popBackStack(startDestination, false)
|
||||
}
|
||||
if (navItem.route != Destinations.startDestination.route) {
|
||||
navController.navigate(navItem.route)
|
||||
} else {
|
||||
navController.graph.startDestinationRoute?.let { startDestination ->
|
||||
navController.popBackStack(startDestination, false)
|
||||
}
|
||||
if (navItem.route != Destinations.startDestination.route) {
|
||||
navController.navigate(navItem.route)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.testTag(navItem.label.uppercase()),
|
||||
@@ -85,7 +86,7 @@ fun ClawNavigationBar(
|
||||
class NavigationItem(
|
||||
val label: String,
|
||||
val route: String,
|
||||
val icon: Painter,
|
||||
val selectedIcon: Painter,
|
||||
val icon: ImageVector,
|
||||
val selectedIcon: ImageVector,
|
||||
val listStateResetCallback: () -> Unit,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright © 2022-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.decorations
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.FastOutLinearInEasing
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.navigation.NavController
|
||||
import dev.msfjarvis.claw.android.ui.navigation.Destinations
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Composable
|
||||
fun ClawNavigationRail(
|
||||
navController: NavController,
|
||||
items: ImmutableList<NavigationItem>,
|
||||
isVisible: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter =
|
||||
slideInHorizontally(
|
||||
// Enters by sliding in from offset -fullWidth to 0.
|
||||
initialOffsetX = { fullWidth -> -fullWidth },
|
||||
animationSpec = tween(durationMillis = AnimationDuration, easing = LinearOutSlowInEasing),
|
||||
),
|
||||
exit =
|
||||
slideOutHorizontally(
|
||||
// Exits by sliding out from offset 0 to -fullWidth.
|
||||
targetOffsetX = { fullWidth -> -fullWidth },
|
||||
animationSpec = tween(durationMillis = AnimationDuration, easing = FastOutLinearInEasing),
|
||||
),
|
||||
modifier = Modifier,
|
||||
) {
|
||||
NavigationRail(modifier = modifier) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
items.forEach { navItem ->
|
||||
val isCurrentDestination = navController.currentDestination?.route == navItem.route
|
||||
NavigationRailItem(
|
||||
icon = {
|
||||
Crossfade(isCurrentDestination, label = "nav-label") {
|
||||
Icon(
|
||||
imageVector = if (it) navItem.selectedIcon else navItem.icon,
|
||||
contentDescription = navItem.label.replaceFirstChar(Char::uppercase),
|
||||
)
|
||||
}
|
||||
},
|
||||
label = { Text(text = navItem.label) },
|
||||
selected = isCurrentDestination,
|
||||
onClick = {
|
||||
if (isCurrentDestination) {
|
||||
navItem.listStateResetCallback()
|
||||
} else {
|
||||
navController.graph.startDestinationRoute?.let { startDestination ->
|
||||
navController.popBackStack(startDestination, false)
|
||||
}
|
||||
if (navItem.route != Destinations.startDestination.route) {
|
||||
navController.navigate(navItem.route)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.testTag(navItem.label.uppercase()),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © 2022 Harsh Shandilya.
|
||||
* Copyright © 2022-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
@@ -19,8 +19,6 @@ import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
|
||||
import dev.msfjarvis.claw.database.local.SavedPost
|
||||
import dev.msfjarvis.claw.model.LinkMetadata
|
||||
import dev.msfjarvis.claw.model.LobstersPostDetails
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
fun Context.getActivity(): ComponentActivity? {
|
||||
return when (this) {
|
||||
@@ -30,14 +28,6 @@ fun Context.getActivity(): ComponentActivity? {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a given [String] into a [LocalDateTime]. This method is only intended to be used for dates
|
||||
* in the format returned by the Lobsters API, and is not a general purpose parsing solution.
|
||||
*/
|
||||
fun String.toLocalDateTime(): LocalDateTime {
|
||||
return LocalDateTime.from(DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(this))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberPostActions(
|
||||
urlLauncher: UrlLauncher,
|
||||
@@ -46,14 +36,17 @@ fun rememberPostActions(
|
||||
): PostActions {
|
||||
return remember {
|
||||
object : PostActions {
|
||||
override fun viewPost(postUrl: String, commentsUrl: String) {
|
||||
override fun viewPost(postId: String, postUrl: String, commentsUrl: String) {
|
||||
viewModel.markPostAsRead(postId)
|
||||
urlLauncher.openUri(postUrl.ifEmpty { commentsUrl })
|
||||
}
|
||||
|
||||
override fun viewComments(postId: String) {
|
||||
navController.navigate(
|
||||
viewModel.markPostAsRead(postId)
|
||||
val currentRoute = navController.currentDestination?.route
|
||||
val newRoute =
|
||||
Destinations.Comments.route.replace(Destinations.Comments.placeholder, postId)
|
||||
)
|
||||
if (currentRoute != Destinations.Comments.route) navController.navigate(newRoute)
|
||||
}
|
||||
|
||||
override fun viewCommentsPage(commentsUrl: String) {
|
||||
|
||||
@@ -6,40 +6,68 @@
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.lists
|
||||
|
||||
import androidx.activity.compose.ReportDrawn
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Inbox
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.msfjarvis.claw.common.posts.PostActions
|
||||
import dev.msfjarvis.claw.common.ui.decorations.MonthHeader
|
||||
import dev.msfjarvis.claw.database.local.SavedPost
|
||||
import java.time.Month
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun DatabasePosts(
|
||||
items: Map<Month, List<SavedPost>>,
|
||||
items: ImmutableMap<Month, List<SavedPost>>,
|
||||
listState: LazyListState,
|
||||
postActions: PostActions,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier,
|
||||
) {
|
||||
items.forEach { (month, posts) ->
|
||||
stickyHeader { MonthHeader(month = month) }
|
||||
items(posts, { it.shortId }) { item ->
|
||||
ListItem(
|
||||
item = item,
|
||||
isSaved = { true },
|
||||
postActions = postActions,
|
||||
ReportDrawn()
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
if (items.isEmpty()) {
|
||||
Column(modifier = Modifier.align(Alignment.Center)) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Inbox,
|
||||
contentDescription = "Empty inbox icon",
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally).size(36.dp),
|
||||
)
|
||||
|
||||
Divider()
|
||||
Text(text = "No saved posts", style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(state = listState) {
|
||||
items.forEach { (month, posts) ->
|
||||
stickyHeader(contentType = "month-header") { MonthHeader(month = month) }
|
||||
items(
|
||||
items = posts,
|
||||
key = { it.shortId },
|
||||
contentType = { "LobstersItem" },
|
||||
) { item ->
|
||||
ListItem(
|
||||
item = item,
|
||||
isSaved = { true },
|
||||
isRead = { false },
|
||||
postActions = postActions,
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,50 @@
|
||||
/*
|
||||
* Copyright © 2021-2022 Harsh Shandilya.
|
||||
* Copyright © 2021-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.lists
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Reply
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import dev.msfjarvis.claw.common.posts.LobstersCard
|
||||
import dev.msfjarvis.claw.common.posts.PostActions
|
||||
import dev.msfjarvis.claw.database.local.SavedPost
|
||||
import me.saket.swipe.SwipeAction
|
||||
import me.saket.swipe.SwipeableActionsBox
|
||||
|
||||
@Composable
|
||||
fun ListItem(
|
||||
item: SavedPost,
|
||||
isSaved: suspend (SavedPost) -> Boolean,
|
||||
isRead: suspend (String) -> Boolean,
|
||||
postActions: PostActions,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val saved by produceState(false, item) { value = isSaved(item) }
|
||||
LobstersCard(
|
||||
post = item,
|
||||
isSaved = saved,
|
||||
postActions = postActions,
|
||||
modifier = modifier,
|
||||
)
|
||||
val read by produceState(false, item.shortId) { value = isRead(item.shortId) }
|
||||
val commentsAction =
|
||||
SwipeAction(
|
||||
icon = rememberVectorPainter(Icons.AutoMirrored.Filled.Reply),
|
||||
background = MaterialTheme.colorScheme.tertiary,
|
||||
onSwipe = { postActions.viewCommentsPage(item.commentsUrl) },
|
||||
)
|
||||
SwipeableActionsBox(
|
||||
endActions = listOf(commentsAction),
|
||||
) {
|
||||
LobstersCard(
|
||||
post = item,
|
||||
isSaved = saved,
|
||||
isRead = read,
|
||||
postActions = postActions,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/*
|
||||
* Copyright © 2021-2022 Harsh Shandilya.
|
||||
* Copyright © 2021-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.lists
|
||||
|
||||
import androidx.activity.compose.ReportDrawnWhen
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -13,36 +14,37 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.items
|
||||
import androidx.paging.compose.itemContentType
|
||||
import androidx.paging.compose.itemKey
|
||||
import dev.msfjarvis.claw.common.posts.PostActions
|
||||
import dev.msfjarvis.claw.common.posts.toDbModel
|
||||
import dev.msfjarvis.claw.common.ui.NetworkError
|
||||
import dev.msfjarvis.claw.common.ui.ProgressBar
|
||||
import dev.msfjarvis.claw.database.local.SavedPost
|
||||
import dev.msfjarvis.claw.model.LobstersPost
|
||||
import dev.msfjarvis.claw.model.toSavedPost
|
||||
import eu.bambooapps.material3.pullrefresh.PullRefreshIndicator
|
||||
import eu.bambooapps.material3.pullrefresh.pullRefresh
|
||||
import eu.bambooapps.material3.pullrefresh.rememberPullRefreshState
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NetworkPosts(
|
||||
lazyPagingItems: LazyPagingItems<LobstersPost>,
|
||||
listState: LazyListState,
|
||||
isPostSaved: suspend (SavedPost) -> Boolean,
|
||||
isPostRead: suspend (String) -> Boolean,
|
||||
postActions: PostActions,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ReportDrawnWhen { lazyPagingItems.itemCount > 0 }
|
||||
val refreshLoadState = lazyPagingItems.loadState.refresh
|
||||
val isRefreshing = refreshLoadState == LoadState.Loading
|
||||
val pullRefreshState = rememberPullRefreshState(isRefreshing, lazyPagingItems::refresh)
|
||||
@@ -57,16 +59,22 @@ fun NetworkPosts(
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
) {
|
||||
items(items = lazyPagingItems, key = { it.shortId }) { item ->
|
||||
items(
|
||||
count = lazyPagingItems.itemCount,
|
||||
key = lazyPagingItems.itemKey { it.shortId },
|
||||
contentType = lazyPagingItems.itemContentType { "LobstersItem" },
|
||||
) { index ->
|
||||
val item = lazyPagingItems[index]
|
||||
if (item != null) {
|
||||
val dbModel = item.toDbModel()
|
||||
val dbModel = item.toSavedPost()
|
||||
ListItem(
|
||||
item = dbModel,
|
||||
isSaved = isPostSaved,
|
||||
isRead = isPostRead,
|
||||
postActions = postActions,
|
||||
)
|
||||
|
||||
Divider()
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
if (lazyPagingItems.loadState.append == LoadState.Loading) {
|
||||
@@ -85,8 +93,7 @@ fun NetworkPosts(
|
||||
refreshing = isRefreshing,
|
||||
state = pullRefreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
backgroundColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = contentColorFor(MaterialTheme.colorScheme.surface),
|
||||
shadowElevation = 6.dp, // From M2 implementation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright © 2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.lists
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SearchOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
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.platform.testTag
|
||||
import androidx.compose.ui.semantics.isTraversalGroup
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import dev.msfjarvis.claw.common.posts.PostActions
|
||||
import dev.msfjarvis.claw.common.ui.SearchBar
|
||||
import dev.msfjarvis.claw.database.local.SavedPost
|
||||
import dev.msfjarvis.claw.model.LobstersPost
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Composable
|
||||
fun SearchList(
|
||||
items: Flow<PagingData<LobstersPost>>,
|
||||
listState: LazyListState,
|
||||
isPostSaved: suspend (SavedPost) -> Boolean,
|
||||
postActions: PostActions,
|
||||
searchQuery: String,
|
||||
setSearchQuery: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val lazyPagingItems = items.collectAsLazyPagingItems()
|
||||
val triggerSearch = { query: String ->
|
||||
setSearchQuery(query)
|
||||
lazyPagingItems.refresh()
|
||||
}
|
||||
DisposableEffect(true) {
|
||||
// Clear search field when navigating away
|
||||
onDispose { setSearchQuery("") }
|
||||
}
|
||||
|
||||
var searchActive by remember { mutableStateOf(false) }
|
||||
Column(modifier = Modifier.semantics { isTraversalGroup = true }.zIndex(1f).fillMaxWidth()) {
|
||||
SearchBar(
|
||||
value = searchQuery,
|
||||
onValueChange = setSearchQuery,
|
||||
onSearch = {
|
||||
triggerSearch(it)
|
||||
searchActive = true
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp).testTag("search_bar"),
|
||||
)
|
||||
if (searchActive) {
|
||||
NetworkPosts(
|
||||
lazyPagingItems = lazyPagingItems,
|
||||
listState = listState,
|
||||
isPostSaved = isPostSaved,
|
||||
isPostRead = { false },
|
||||
postActions = postActions,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Column(modifier = Modifier.align(Alignment.Center)) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.SearchOff,
|
||||
contentDescription = "No search results",
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally).size(36.dp),
|
||||
)
|
||||
Text(text = "Nothing to see here", style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © 2021-2022 Harsh Shandilya.
|
||||
* Copyright © 2021-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
@@ -21,6 +21,10 @@ sealed class Destinations {
|
||||
override val route = "saved"
|
||||
}
|
||||
|
||||
object Search : Destinations() {
|
||||
override val route = "search"
|
||||
}
|
||||
|
||||
object Comments : Destinations() {
|
||||
const val placeholder = "{postId}"
|
||||
override val route = "comments/$placeholder"
|
||||
@@ -31,6 +35,10 @@ sealed class Destinations {
|
||||
override val route = "user/$placeholder"
|
||||
}
|
||||
|
||||
object DataTransfer : Destinations() {
|
||||
override val route: String = "datatransfer"
|
||||
}
|
||||
|
||||
companion object {
|
||||
val startDestination
|
||||
get() = Hottest
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright © 2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.ui.navigation
|
||||
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
|
||||
enum class ClawNavigationType {
|
||||
BOTTOM_NAVIGATION,
|
||||
NAVIGATION_RAIL;
|
||||
|
||||
companion object {
|
||||
fun fromSize(windowSize: WindowWidthSizeClass): ClawNavigationType {
|
||||
return when (windowSize) {
|
||||
WindowWidthSizeClass.Medium,
|
||||
WindowWidthSizeClass.Expanded -> NAVIGATION_RAIL
|
||||
else -> BOTTOM_NAVIGATION
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © 2022 Harsh Shandilya.
|
||||
* Copyright © 2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
|
||||
@@ -6,25 +6,43 @@
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import com.deliveryhero.whetstone.app.ApplicationScope
|
||||
import com.deliveryhero.whetstone.viewmodel.ContributesViewModel
|
||||
import com.slack.eithernet.ApiResult.Failure
|
||||
import com.slack.eithernet.ApiResult.Success
|
||||
import dev.msfjarvis.claw.android.injection.IODispatcher
|
||||
import com.squareup.anvil.annotations.optional.ForScope
|
||||
import dev.msfjarvis.claw.android.glance.SavedPostsWidget
|
||||
import dev.msfjarvis.claw.android.paging.LobstersPagingSource
|
||||
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.PAGE_SIZE
|
||||
import dev.msfjarvis.claw.android.paging.LobstersPagingSource.Companion.STARTING_PAGE_INDEX
|
||||
import dev.msfjarvis.claw.android.ui.toLocalDateTime
|
||||
import dev.msfjarvis.claw.android.paging.SearchPagingSource
|
||||
import dev.msfjarvis.claw.api.LobstersApi
|
||||
import dev.msfjarvis.claw.api.LobstersSearchApi
|
||||
import dev.msfjarvis.claw.core.injection.IODispatcher
|
||||
import dev.msfjarvis.claw.core.injection.MainDispatcher
|
||||
import dev.msfjarvis.claw.database.local.SavedPost
|
||||
import dev.msfjarvis.claw.model.Comment
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.time.LocalDateTime
|
||||
import java.time.Month
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import javax.inject.Inject
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -36,11 +54,18 @@ class ClawViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val api: LobstersApi,
|
||||
private val searchApi: LobstersSearchApi,
|
||||
private val commentsRepository: CommentsRepository,
|
||||
private val readPostsRepository: ReadPostsRepository,
|
||||
private val savedPostsRepository: SavedPostsRepository,
|
||||
private val linkMetadataRepository: LinkMetadataRepository,
|
||||
private val dataTransferRepository: DataTransferRepository,
|
||||
private val pagingSourceFactory: LobstersPagingSource.Factory,
|
||||
private val searchPagingSourceFactory: SearchPagingSource.Factory,
|
||||
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
|
||||
@ForScope(ApplicationScope::class) context: Context,
|
||||
) : AndroidViewModel(context as Application) {
|
||||
private val hottestPostsPager =
|
||||
Pager(PagingConfig(pageSize = PAGE_SIZE), initialKey = STARTING_PAGE_INDEX) {
|
||||
pagingSourceFactory.create(api::getHottestPosts)
|
||||
@@ -49,6 +74,10 @@ constructor(
|
||||
Pager(PagingConfig(pageSize = PAGE_SIZE), initialKey = STARTING_PAGE_INDEX) {
|
||||
pagingSourceFactory.create(api::getNewestPosts)
|
||||
}
|
||||
private val searchResultsPager =
|
||||
Pager(PagingConfig(pageSize = PAGE_SIZE), initialKey = STARTING_PAGE_INDEX) {
|
||||
searchPagingSourceFactory.create { searchApi.searchPosts(searchQuery, it) }
|
||||
}
|
||||
|
||||
val hottestPosts
|
||||
get() = hottestPostsPager.flow
|
||||
@@ -56,19 +85,30 @@ constructor(
|
||||
val newestPosts
|
||||
get() = newestPostsPager.flow
|
||||
|
||||
private val savedPostsFlow
|
||||
get() = savedPostsRepository.savedPosts
|
||||
|
||||
val savedPosts
|
||||
get() = savedPostsFlow.map(::mapSavedPosts)
|
||||
get() =
|
||||
savedPostsRepository.savedPosts.map {
|
||||
it.sortedByDescending { post -> post.createdAt.toLocalDateTime() }
|
||||
}
|
||||
|
||||
private fun mapSavedPosts(items: List<SavedPost>): Map<Month, List<SavedPost>> {
|
||||
val sorted = items.sortedByDescending { post -> post.createdAt.toLocalDateTime() }
|
||||
return sorted.groupBy { post -> post.createdAt.toLocalDateTime().month }
|
||||
val savedPostsByMonth
|
||||
get() = savedPosts.map(::mapSavedPosts)
|
||||
|
||||
val searchResults
|
||||
get() = searchResultsPager.flow
|
||||
|
||||
var searchQuery by mutableStateOf("")
|
||||
|
||||
private fun mapSavedPosts(items: List<SavedPost>): ImmutableMap<Month, List<SavedPost>> {
|
||||
val sorted =
|
||||
items.sortedWith { post1, post2 ->
|
||||
post2.createdAt.toLocalDateTime().compareTo(post1.createdAt.toLocalDateTime())
|
||||
}
|
||||
return sorted.groupBy { post -> post.createdAt.toLocalDateTime().month }.toImmutableMap()
|
||||
}
|
||||
|
||||
suspend fun isPostSaved(post: SavedPost): Boolean {
|
||||
return savedPostsFlow.first().any { savedPost -> savedPost.shortId == post.shortId }
|
||||
return savedPosts.first().any { savedPost -> savedPost.shortId == post.shortId }
|
||||
}
|
||||
|
||||
fun toggleSave(post: SavedPost) {
|
||||
@@ -79,6 +119,10 @@ constructor(
|
||||
} else {
|
||||
savedPostsRepository.savePost(post)
|
||||
}
|
||||
val newPosts = savedPosts.first()
|
||||
withContext(mainDispatcher) {
|
||||
SavedPostsWidget(newPosts.take(50)).updateAll(getApplication())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,10 +143,10 @@ constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getSeenComments(postId: String) = savedPostsRepository.getSeenComments(postId)
|
||||
suspend fun getSeenComments(postId: String) = commentsRepository.getSeenComments(postId)
|
||||
|
||||
fun markSeenComments(postId: String, comments: List<Comment>) {
|
||||
viewModelScope.launch { savedPostsRepository.markSeenComments(postId, comments) }
|
||||
viewModelScope.launch { commentsRepository.markSeenComments(postId, comments) }
|
||||
}
|
||||
|
||||
suspend fun getLinkMetadata(url: String) =
|
||||
@@ -118,4 +162,24 @@ constructor(
|
||||
is Failure.ApiFailure -> throw IOException("API returned an invalid response")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun importPosts(input: InputStream) = dataTransferRepository.importPosts(input)
|
||||
|
||||
suspend fun exportPosts(output: OutputStream) = dataTransferRepository.exportPosts(output)
|
||||
|
||||
fun markPostAsRead(postId: String) {
|
||||
viewModelScope.launch { readPostsRepository.markRead(postId) }
|
||||
}
|
||||
|
||||
suspend fun isPostRead(postId: String) = readPostsRepository.isRead(postId)
|
||||
|
||||
/**
|
||||
* Parses a given [String] into a [LocalDateTime]. This method is only intended to be used for
|
||||
* dates in the format returned by the Lobsters API, and is not a general purpose parsing
|
||||
* solution.
|
||||
*/
|
||||
private fun String.toLocalDateTime(): LocalDateTime {
|
||||
if (isEmpty()) return LocalDateTime.now(ZoneId.systemDefault())
|
||||
return LocalDateTime.from(DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(this))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright © 2021-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.viewmodel
|
||||
|
||||
import dev.msfjarvis.claw.core.injection.DatabaseDispatcher
|
||||
import dev.msfjarvis.claw.database.local.PostComments
|
||||
import dev.msfjarvis.claw.database.local.PostCommentsQueries
|
||||
import dev.msfjarvis.claw.model.Comment
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class CommentsRepository
|
||||
@Inject
|
||||
constructor(
|
||||
private val postCommentsQueries: PostCommentsQueries,
|
||||
@DatabaseDispatcher private val dbDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
suspend fun getSeenComments(postId: String) =
|
||||
withContext(dbDispatcher) { postCommentsQueries.getCommentIds(postId).executeAsOneOrNull() }
|
||||
|
||||
suspend fun markSeenComments(postId: String, comments: List<Comment>) {
|
||||
withContext(dbDispatcher) {
|
||||
postCommentsQueries.rememberComments(PostComments(postId, comments.map { it.shortId }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright © 2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.viewmodel
|
||||
|
||||
import dev.msfjarvis.claw.core.injection.DatabaseDispatcher
|
||||
import dev.msfjarvis.claw.core.injection.IODispatcher
|
||||
import dev.msfjarvis.claw.database.SavedPostSerializer
|
||||
import dev.msfjarvis.claw.database.local.SavedPost
|
||||
import dev.msfjarvis.claw.database.local.SavedPostQueries
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import kotlinx.serialization.json.encodeToStream
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
class DataTransferRepository
|
||||
@Inject
|
||||
constructor(
|
||||
private val json: Json,
|
||||
private val savedPostQueries: SavedPostQueries,
|
||||
@DatabaseDispatcher private val dbDispatcher: CoroutineDispatcher,
|
||||
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
private val serializer = ListSerializer(SavedPostSerializer)
|
||||
|
||||
suspend fun importPosts(input: InputStream) {
|
||||
val posts: List<SavedPost> =
|
||||
withContext(ioDispatcher) {
|
||||
json.decodeFromStream(
|
||||
serializer,
|
||||
input,
|
||||
)
|
||||
}
|
||||
withContext(dbDispatcher) {
|
||||
savedPostQueries.transaction { posts.forEach(savedPostQueries::insertOrReplacePost) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun exportPosts(output: OutputStream) {
|
||||
val posts = withContext(dbDispatcher) { savedPostQueries.selectAllPosts().executeAsList() }
|
||||
withContext(ioDispatcher) { json.encodeToStream(serializer, posts, output) }
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,26 @@
|
||||
/*
|
||||
* Copyright © 2022 Harsh Shandilya.
|
||||
* Copyright © 2022-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.viewmodel
|
||||
|
||||
import dev.msfjarvis.claw.metadata.MetadataExtractor
|
||||
import dev.msfjarvis.claw.core.injection.IODispatcher
|
||||
import dev.msfjarvis.claw.model.LinkMetadata
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.saket.unfurl.Unfurler
|
||||
|
||||
class LinkMetadataRepository
|
||||
@Inject
|
||||
constructor(
|
||||
private val metadataExtractor: MetadataExtractor,
|
||||
private val unfurler: Unfurler,
|
||||
@IODispatcher private val dispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
suspend fun getLinkMetadata(url: String): LinkMetadata {
|
||||
return metadataExtractor.getExtractedMetadata(url)
|
||||
val result = withContext(dispatcher) { unfurler.unfurl(url) }
|
||||
return LinkMetadata(url, result?.favicon?.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright © 2021-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.android.viewmodel
|
||||
|
||||
import dev.msfjarvis.claw.core.injection.DatabaseDispatcher
|
||||
import dev.msfjarvis.claw.database.local.ReadPostsQueries
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class ReadPostsRepository
|
||||
@Inject
|
||||
constructor(
|
||||
private val readPostsQueries: ReadPostsQueries,
|
||||
@DatabaseDispatcher private val dbDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
|
||||
suspend fun markRead(postId: String) {
|
||||
withContext(dbDispatcher) { readPostsQueries.markRead(postId) }
|
||||
}
|
||||
|
||||
suspend fun isRead(postId: String): Boolean =
|
||||
withContext(dbDispatcher) { readPostsQueries.isRead(postId).executeAsOneOrNull() != null }
|
||||
}
|
||||
@@ -8,11 +8,9 @@ package dev.msfjarvis.claw.android.viewmodel
|
||||
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import dev.msfjarvis.claw.android.injection.DatabaseDispatcher
|
||||
import dev.msfjarvis.claw.database.LobstersDatabase
|
||||
import dev.msfjarvis.claw.database.local.PostComments
|
||||
import dev.msfjarvis.claw.core.injection.DatabaseDispatcher
|
||||
import dev.msfjarvis.claw.database.local.SavedPost
|
||||
import dev.msfjarvis.claw.model.Comment
|
||||
import dev.msfjarvis.claw.database.local.SavedPostQueries
|
||||
import io.github.aakira.napier.Napier
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -21,11 +19,9 @@ import kotlinx.coroutines.withContext
|
||||
class SavedPostsRepository
|
||||
@Inject
|
||||
constructor(
|
||||
database: LobstersDatabase,
|
||||
private val savedPostQueries: SavedPostQueries,
|
||||
@DatabaseDispatcher private val dbDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
private val savedPostQueries = database.savedPostQueries
|
||||
private val postCommentsQueries = database.postCommentsQueries
|
||||
val savedPosts = savedPostQueries.selectAllPosts().asFlow().mapToList(dbDispatcher)
|
||||
|
||||
suspend fun savePost(post: SavedPost) {
|
||||
@@ -47,15 +43,6 @@ constructor(
|
||||
withContext(dbDispatcher) { savedPostQueries.deletePost(post.shortId) }
|
||||
}
|
||||
|
||||
suspend fun getSeenComments(postId: String) =
|
||||
withContext(dbDispatcher) { postCommentsQueries.getCommentIds(postId).executeAsOneOrNull() }
|
||||
|
||||
suspend fun markSeenComments(postId: String, comments: List<Comment>) {
|
||||
withContext(dbDispatcher) {
|
||||
postCommentsQueries.rememberComments(PostComments(postId, comments.map { it.shortId }))
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "SavedPostsRepository"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright © 2022 Harsh Shandilya.
|
||||
* Copyright © 2022-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
@@ -7,16 +7,18 @@
|
||||
package dev.msfjarvis.claw.android.work
|
||||
|
||||
import android.content.Context
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.deliveryhero.whetstone.ForScope
|
||||
import com.deliveryhero.whetstone.worker.ContributesWorker
|
||||
import com.deliveryhero.whetstone.worker.WorkerScope
|
||||
import com.slack.eithernet.ApiResult.Success
|
||||
import com.squareup.anvil.annotations.optional.ForScope
|
||||
import dev.msfjarvis.claw.android.glance.SavedPostsWidget
|
||||
import dev.msfjarvis.claw.android.viewmodel.SavedPostsRepository
|
||||
import dev.msfjarvis.claw.api.LobstersApi
|
||||
import dev.msfjarvis.claw.common.posts.toDbModel
|
||||
import dev.msfjarvis.claw.model.LobstersPostDetails
|
||||
import dev.msfjarvis.claw.model.toSavedPost
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
@@ -40,9 +42,9 @@ constructor(
|
||||
.first()
|
||||
.map { post -> lobstersApi.getPostDetails(post.shortId) }
|
||||
.filterIsInstance<Success<LobstersPostDetails>>()
|
||||
.map { result -> result.value.toDbModel() }
|
||||
.toList()
|
||||
.map { result -> result.value.toSavedPost() }
|
||||
.let { savedPostsRepository.savePosts(it) }
|
||||
SavedPostsWidget(savedPostsRepository.savedPosts.first().take(50)).updateAll(applicationContext)
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!--
|
||||
~ Copyright © 2023 Harsh Shandilya.
|
||||
~ Use of this source code is governed by an MIT-style
|
||||
~ license that can be found in the LICENSE file or at
|
||||
~ https://opensource.org/licenses/MIT.
|
||||
-->
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 38 B After Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 38 B After Width: | Height: | Size: 44 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 38 B After Width: | Height: | Size: 46 B |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 38 B After Width: | Height: | Size: 52 B |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 6.1 KiB |
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<resources>
|
||||
<bool name="use_light_status_bar">false</bool>
|
||||
</resources>
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<resources>
|
||||
<bool name="use_light_status_bar">true</bool>
|
||||
</resources>
|
||||
@@ -1,4 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!--
|
||||
~ Copyright © 2021-2023 Harsh Shandilya.
|
||||
~ Use of this source code is governed by an MIT-style
|
||||
~ license that can be found in the LICENSE file or at
|
||||
~ https://opensource.org/licenses/MIT.
|
||||
-->
|
||||
<resources>
|
||||
<string name="app_name">Claw</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<!--
|
||||
~ Copyright © 2021-2023 Harsh Shandilya.
|
||||
~ Use of this source code is governed by an MIT-style
|
||||
~ license that can be found in the LICENSE file or at
|
||||
~ https://opensource.org/licenses/MIT.
|
||||
-->
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Claw.SplashScreen" parent="Theme.SplashScreen">
|
||||
@@ -14,7 +19,5 @@
|
||||
<item name="android:windowActionModeOverlay">true</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Claw" parent="Base.Theme.Claw">
|
||||
<item name="android:windowLightStatusBar">@bool/use_light_status_bar</item>
|
||||
</style>
|
||||
<style name="Theme.Claw" parent="Base.Theme.Claw" />
|
||||
</resources>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © 2022-2023 Harsh Shandilya.
|
||||
~ Use of this source code is governed by an MIT-style
|
||||
~ license that can be found in the LICENSE file or at
|
||||
~ https://opensource.org/licenses/MIT.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<include
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright © 2022-2023 Harsh Shandilya.
|
||||
~ Use of this source code is governed by an MIT-style
|
||||
~ license that can be found in the LICENSE file or at
|
||||
~ https://opensource.org/licenses/MIT.
|
||||
-->
|
||||
<full-backup-content>
|
||||
<include
|
||||
domain="database"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<!--
|
||||
~ Copyright © 2023 Harsh Shandilya.
|
||||
~ Use of this source code is governed by an MIT-style
|
||||
~ license that can be found in the LICENSE file or at
|
||||
~ https://opensource.org/licenses/MIT.
|
||||
-->
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:initialLayout="@layout/glance_default_loading_layout"
|
||||
android:minWidth="349dp"
|
||||
android:minHeight="110dp"
|
||||
android:minResizeWidth="349dp"
|
||||
android:minResizeHeight="110dp"
|
||||
android:resizeMode="vertical|horizontal"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:widgetCategory="home_screen" />
|
||||
@@ -1,20 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<!-- Required: set your sentry.io project identifier (DSN) -->
|
||||
<meta-data android:name="io.sentry.dsn" android:value="${sentryDsn}" />
|
||||
|
||||
<!-- enable automatic breadcrumbs for user interactions (clicks, swipes, scrolls) -->
|
||||
<meta-data android:name="io.sentry.traces.user-interaction.enable" android:value="true" />
|
||||
<meta-data android:name="io.sentry.breadcrumbs.user-interaction" android:value="true" />
|
||||
<!-- enable screenshot for crashes -->
|
||||
<meta-data android:name="io.sentry.attach-screenshot" android:value="true" />
|
||||
<!-- enable view hierarchy for crashes -->
|
||||
<meta-data android:name="io.sentry.attach-view-hierarchy" android:value="true" />
|
||||
|
||||
<!-- enable the performance API by setting a sample-rate, adjust in production env -->
|
||||
<meta-data android:name="io.sentry.traces.sample-rate" android:value="0.8" />
|
||||
<!-- enable profiling when starting transactions, adjust in production env -->
|
||||
<meta-data android:name="io.sentry.traces.profiling.sample-rate" android:value="0.8" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,5 +1,5 @@
|
||||
#
|
||||
# This file was automatically generated by 'versioning-plugin'. DO NOT EDIT MANUALLY.
|
||||
#
|
||||
versioning-plugin.versionCode=12300
|
||||
versioning-plugin.versionName=1.23.0
|
||||
versioning-plugin.versionCode=13700
|
||||
versioning-plugin.versionName=1.37.0
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/*
|
||||
* Copyright © 2021-2022 Harsh Shandilya.
|
||||
* Copyright © 2021-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
@file:Suppress("DSL_SCOPE_VIOLATION", "UnstableApiUsage")
|
||||
import dev.msfjarvis.claw.gradle.addTestDependencies
|
||||
|
||||
plugins {
|
||||
id("dev.msfjarvis.claw.kotlin-android")
|
||||
id("dev.msfjarvis.claw.android-library")
|
||||
id("dev.msfjarvis.claw.kotlin-android")
|
||||
alias(libs.plugins.anvil)
|
||||
alias(libs.plugins.whetstone)
|
||||
}
|
||||
@@ -18,14 +18,16 @@ android { namespace = "dev.msfjarvis.claw.api" }
|
||||
anvil { generateDaggerFactories.set(true) }
|
||||
|
||||
dependencies {
|
||||
api(projects.model)
|
||||
api(libs.retrofit)
|
||||
api(libs.eithernet)
|
||||
api(libs.retrofit)
|
||||
api(projects.model)
|
||||
|
||||
implementation(libs.dagger)
|
||||
implementation(libs.javax.inject)
|
||||
implementation(libs.jsoup)
|
||||
|
||||
testImplementation(testFixtures(libs.eithernet))
|
||||
testImplementation(libs.kotest.assertions.core)
|
||||
testImplementation(libs.kotest.runner.junit5)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.kotlinx.serialization.json)
|
||||
testImplementation(libs.retrofit.kotlinxSerializationConverter)
|
||||
addTestDependencies(project)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<issues format="6" by="lint 8.0.0-alpha08" type="baseline" client="gradle" dependencies="false" name="AGP (8.0.0-alpha08)" variant="all" version="8.0.0-alpha08">
|
||||
<issues format="6" by="lint 8.3.0-alpha02" type="baseline" client="gradle" dependencies="true" name="AGP (8.3.0-alpha02)" variant="all" version="8.3.0-alpha02">
|
||||
|
||||
</issues>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright © 2022-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.api
|
||||
|
||||
@JvmInline value class CSRFToken(val value: String)
|
||||
@@ -9,7 +9,6 @@ package dev.msfjarvis.claw.api
|
||||
import com.slack.eithernet.ApiResult
|
||||
import dev.msfjarvis.claw.model.LobstersPost
|
||||
import dev.msfjarvis.claw.model.LobstersPostDetails
|
||||
import dev.msfjarvis.claw.model.Tags
|
||||
import dev.msfjarvis.claw.model.User
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
@@ -27,14 +26,10 @@ interface LobstersApi {
|
||||
@GET("s/{postId}.json")
|
||||
suspend fun getPostDetails(@Path("postId") postId: String): ApiResult<LobstersPostDetails, Unit>
|
||||
|
||||
@GET("u/{username}.json")
|
||||
@GET("~{username}.json")
|
||||
suspend fun getUser(@Path("username") username: String): ApiResult<User, Unit>
|
||||
|
||||
@GET("t/{tags}.json")
|
||||
suspend fun getPostsByTags(
|
||||
@Path("tags") tag: Tags,
|
||||
@Query("page") page: Int,
|
||||
): ApiResult<List<LobstersPost>, Unit>
|
||||
@GET("/") suspend fun getCSRFToken(): ApiResult<CSRFToken, Unit>
|
||||
|
||||
companion object {
|
||||
const val BASE_URL = "https://lobste.rs"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright © 2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.api
|
||||
|
||||
import com.slack.eithernet.ApiResult
|
||||
import dev.msfjarvis.claw.model.LobstersPost
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface LobstersSearchApi {
|
||||
@GET("/search?what=stories&order=newest")
|
||||
suspend fun searchPosts(
|
||||
@Query("q") searchQuery: String,
|
||||
@Query("page") page: Int,
|
||||
): ApiResult<List<LobstersPost>, Unit>
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright © 2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.api.converters
|
||||
|
||||
import dev.msfjarvis.claw.api.CSRFToken
|
||||
import dev.msfjarvis.claw.api.LobstersApi
|
||||
import java.lang.reflect.Type
|
||||
import okhttp3.ResponseBody
|
||||
import org.jsoup.Jsoup
|
||||
import retrofit2.Converter
|
||||
import retrofit2.Retrofit
|
||||
|
||||
object CSRFTokenConverter : Converter<ResponseBody, CSRFToken> {
|
||||
override fun convert(value: ResponseBody): CSRFToken {
|
||||
val token =
|
||||
Jsoup.parse(value.string(), LobstersApi.BASE_URL)
|
||||
.select("meta[name=\"csrf-token\"]")
|
||||
.first()!!
|
||||
.attr("content")
|
||||
return CSRFToken(token)
|
||||
}
|
||||
|
||||
object Factory : Converter.Factory() {
|
||||
override fun responseBodyConverter(
|
||||
type: Type,
|
||||
annotations: Array<out Annotation>,
|
||||
retrofit: Retrofit,
|
||||
): Converter<ResponseBody, CSRFToken> {
|
||||
return CSRFTokenConverter
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright © 2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.api.converters
|
||||
|
||||
import dev.msfjarvis.claw.api.LobstersApi
|
||||
import dev.msfjarvis.claw.model.LobstersPost
|
||||
import dev.msfjarvis.claw.model.User
|
||||
import java.lang.reflect.Type
|
||||
import okhttp3.ResponseBody
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Element
|
||||
import org.jsoup.select.Elements
|
||||
import retrofit2.Converter
|
||||
import retrofit2.Retrofit
|
||||
|
||||
object SearchConverter : Converter<ResponseBody, List<LobstersPost>> {
|
||||
override fun convert(value: ResponseBody): List<LobstersPost> {
|
||||
val elements =
|
||||
Jsoup.parse(value.string(), LobstersApi.BASE_URL).select("div.story_liner.h-entry")
|
||||
return elements.map(::parsePost)
|
||||
}
|
||||
|
||||
private fun parsePost(elem: Element): LobstersPost {
|
||||
val parent = elem.parent() ?: error("$elem must have a parent")
|
||||
val shortId = parent.attr("data-shortid")
|
||||
val titleElement = elem.select("span.link.h-cite > a")
|
||||
val title = titleElement.text()
|
||||
val url = titleElement.attr("href")
|
||||
val tags = elem.select("span.tags > a").map(Element::text)
|
||||
val (commentCount, commentsUrl) = getCommentsData(elem.select("span.comments_label"))
|
||||
val submitter =
|
||||
getSubmitter(elem.select("div.byline").first() ?: error("No byline element found"))
|
||||
return LobstersPost(
|
||||
shortId = shortId,
|
||||
title = title,
|
||||
url = url,
|
||||
commentCount = commentCount,
|
||||
commentsUrl = commentsUrl,
|
||||
tags = tags,
|
||||
submitter = submitter,
|
||||
// The value of these fields is irrelevant for our use case
|
||||
createdAt = "",
|
||||
description = "",
|
||||
)
|
||||
}
|
||||
|
||||
private fun getCommentsData(elem: Elements): Pair<Int, String> {
|
||||
val linkElement = elem.select("a")
|
||||
val countString = linkElement.text().trimStart().substringBefore(" ")
|
||||
val commentsUrl = LobstersApi.BASE_URL + linkElement.attr("href")
|
||||
return (countString.toIntOrNull() ?: 0) to commentsUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a bare-bones [User] object given a byline [elem]. We only need this to be usable for
|
||||
* displaying in a list.
|
||||
*/
|
||||
private fun getSubmitter(elem: Element): User {
|
||||
val userElement = elem.select("a.u-author")
|
||||
val avatarElement = elem.select("img.avatar")
|
||||
val username = userElement.text()
|
||||
val avatarUrl = avatarElement.attr("src")
|
||||
return User(
|
||||
username = username,
|
||||
about = "",
|
||||
invitedBy = null,
|
||||
avatarUrl = avatarUrl,
|
||||
createdAt = "",
|
||||
)
|
||||
}
|
||||
|
||||
object Factory : Converter.Factory() {
|
||||
override fun responseBodyConverter(
|
||||
type: Type,
|
||||
annotations: Array<out Annotation>,
|
||||
retrofit: Retrofit
|
||||
): Converter<ResponseBody, List<LobstersPost>> {
|
||||
return SearchConverter
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
/*
|
||||
* Copyright © 2021-2022 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.api.injection
|
||||
|
||||
import com.deliveryhero.whetstone.app.ApplicationScope
|
||||
import com.slack.eithernet.ApiResultCallAdapterFactory
|
||||
import com.slack.eithernet.ApiResultConverterFactory
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dev.msfjarvis.claw.api.LobstersApi
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.Converter
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
|
||||
@Module
|
||||
@ContributesTo(ApplicationScope::class)
|
||||
object ApiModule {
|
||||
@Provides
|
||||
fun provideRetrofit(
|
||||
client: OkHttpClient,
|
||||
converterFactories: Set<@JvmSuppressWildcards Converter.Factory>,
|
||||
): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
.client(client)
|
||||
.baseUrl(LobstersApi.BASE_URL)
|
||||
.addConverterFactory(ApiResultConverterFactory)
|
||||
.addCallAdapterFactory(ApiResultCallAdapterFactory)
|
||||
.apply { converterFactories.forEach(this::addConverterFactory) }
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideApi(retrofit: Retrofit): LobstersApi {
|
||||
return retrofit.create()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright © 2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.api.injection
|
||||
|
||||
import com.deliveryhero.whetstone.app.ApplicationScope
|
||||
import com.slack.eithernet.ApiResultCallAdapterFactory
|
||||
import com.slack.eithernet.ApiResultConverterFactory
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.multibindings.IntKey
|
||||
import dagger.multibindings.IntoMap
|
||||
import dev.msfjarvis.claw.api.LobstersApi
|
||||
import dev.msfjarvis.claw.api.LobstersSearchApi
|
||||
import dev.msfjarvis.claw.api.converters.CSRFTokenConverter
|
||||
import dev.msfjarvis.claw.api.converters.SearchConverter
|
||||
import javax.inject.Qualifier
|
||||
import okhttp3.OkHttpClient
|
||||
import retrofit2.CallAdapter
|
||||
import retrofit2.Converter
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
|
||||
/**
|
||||
* Ideally the multibindings used here would only use [dagger.multibindings.IntoSet], but its lack
|
||||
* of ordering guarantees means that we roll a die on each app launch that [Converter]s and
|
||||
* [CallAdapter]s are in the correct order to be able to deserialize responses. Thus, the module
|
||||
* uses [IntoMap] with [IntKey]s to fake the presence of a fixed order by sorting on the key of the
|
||||
* injected [Map]s when injecting them into [Retrofit].
|
||||
*/
|
||||
@Module
|
||||
@ContributesTo(ApplicationScope::class)
|
||||
object RetrofitModule {
|
||||
@Provides
|
||||
fun provideRetrofit(
|
||||
client: OkHttpClient,
|
||||
converterFactories: Map<Int, @JvmSuppressWildcards Converter.Factory>,
|
||||
callAdapterFactories: Map<Int, @JvmSuppressWildcards CallAdapter.Factory>,
|
||||
@BaseUrl baseUrl: String,
|
||||
): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
.client(client)
|
||||
.baseUrl(baseUrl)
|
||||
.apply { converterFactories.toSortedMap().values.forEach(this::addConverterFactory) }
|
||||
.apply { callAdapterFactories.toSortedMap().values.forEach(this::addCallAdapterFactory) }
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@SearchApi
|
||||
fun provideSearchApiRetrofit(
|
||||
client: OkHttpClient,
|
||||
@SearchApi converterFactories: List<@JvmSuppressWildcards Converter.Factory>,
|
||||
callAdapterFactories: Map<Int, @JvmSuppressWildcards CallAdapter.Factory>,
|
||||
@BaseUrl baseUrl: String,
|
||||
): Retrofit {
|
||||
return Retrofit.Builder()
|
||||
.client(client)
|
||||
.baseUrl(baseUrl)
|
||||
.apply { converterFactories.forEach(this::addConverterFactory) }
|
||||
.apply { callAdapterFactories.toSortedMap().values.forEach(this::addCallAdapterFactory) }
|
||||
.addConverterFactory(SearchConverter.Factory)
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides fun provideApi(retrofit: Retrofit): LobstersApi = retrofit.create()
|
||||
|
||||
@Provides
|
||||
fun provideSearchApi(@SearchApi retrofit: Retrofit): LobstersSearchApi = retrofit.create()
|
||||
|
||||
@Provides
|
||||
@IntKey(0)
|
||||
@IntoMap
|
||||
fun provideApiResultConverter(): Converter.Factory = ApiResultConverterFactory
|
||||
|
||||
@Provides
|
||||
@IntKey(0)
|
||||
@IntoMap
|
||||
fun provideApiResultCallAdapter(): CallAdapter.Factory = ApiResultCallAdapterFactory
|
||||
|
||||
@Provides
|
||||
@IntKey(Int.MAX_VALUE)
|
||||
@IntoMap
|
||||
fun provideCSRFTokenConverter(): Converter.Factory = CSRFTokenConverter.Factory
|
||||
|
||||
@Provides
|
||||
@SearchApi
|
||||
fun provideConverters(): List<@JvmSuppressWildcards Converter.Factory> =
|
||||
listOf(
|
||||
ApiResultConverterFactory,
|
||||
SearchConverter.Factory,
|
||||
)
|
||||
|
||||
@Provides @BaseUrl fun provideBaseUrl(): String = LobstersApi.BASE_URL
|
||||
}
|
||||
|
||||
@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class BaseUrl
|
||||
|
||||
/** Internal Dagger [Qualifier] to identify dependencies exclusive to [LobstersSearchApi]. */
|
||||
@Qualifier @Retention(AnnotationRetention.RUNTIME) private annotation class SearchApi
|
||||
@@ -6,65 +6,57 @@
|
||||
*/
|
||||
package dev.msfjarvis.claw.api
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.slack.eithernet.ApiResult.Success
|
||||
import com.slack.eithernet.test.newEitherNetController
|
||||
import dev.msfjarvis.claw.model.LobstersPost
|
||||
import dev.msfjarvis.claw.model.LobstersPostDetails
|
||||
import dev.msfjarvis.claw.model.Tags
|
||||
import dev.msfjarvis.claw.model.User
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.types.shouldBeTypeOf
|
||||
import dev.msfjarvis.claw.util.TestUtils.assertIs
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class ApiTest : FunSpec() {
|
||||
class ApiTest {
|
||||
private val wrapper = ApiWrapper(newEitherNetController())
|
||||
private val api
|
||||
get() = wrapper.api
|
||||
|
||||
init {
|
||||
test("api gets correct number of items") {
|
||||
val posts = api.getHottestPosts(1)
|
||||
posts.shouldBeTypeOf<Success<List<LobstersPost>>>()
|
||||
posts.value shouldHaveSize 25
|
||||
}
|
||||
@Test
|
||||
fun `api gets correct number of items`() = runTest {
|
||||
val posts = api.getHottestPosts(1)
|
||||
assertIs<Success<List<LobstersPost>>>(posts)
|
||||
assertThat(posts.value).hasSize(25)
|
||||
}
|
||||
|
||||
test("posts with no urls") {
|
||||
val posts = api.getHottestPosts(1)
|
||||
posts.shouldBeTypeOf<Success<List<LobstersPost>>>()
|
||||
val commentsOnlyPosts = posts.value.asSequence().filter { it.url.isEmpty() }.toSet()
|
||||
commentsOnlyPosts shouldHaveSize 2
|
||||
}
|
||||
@Test
|
||||
fun `posts with no urls`() = runTest {
|
||||
val posts = api.getHottestPosts(1)
|
||||
assertIs<Success<List<LobstersPost>>>(posts)
|
||||
val commentsOnlyPosts = posts.value.asSequence().filter { it.url.isEmpty() }.toSet()
|
||||
assertThat(commentsOnlyPosts).hasSize(2)
|
||||
}
|
||||
|
||||
test("post details with comments") {
|
||||
val postDetails = api.getPostDetails("tdfoqh")
|
||||
postDetails.shouldBeTypeOf<Success<LobstersPostDetails>>()
|
||||
postDetails.value.comments shouldHaveSize 7
|
||||
}
|
||||
@Test
|
||||
fun `post details with comments`() = runTest {
|
||||
val postDetails = api.getPostDetails("tdfoqh")
|
||||
assertIs<Success<LobstersPostDetails>>(postDetails)
|
||||
assertThat(postDetails.value.comments).hasSize(10)
|
||||
}
|
||||
|
||||
test("get user details") {
|
||||
val user = api.getUser("msfjarvis")
|
||||
user.shouldBeTypeOf<Success<User>>()
|
||||
user.value.username shouldBe "msfjarvis"
|
||||
}
|
||||
@Test
|
||||
fun `get user details`() = runTest {
|
||||
val user = api.getUser("msfjarvis")
|
||||
assertIs<Success<User>>(user)
|
||||
assertThat(user.value.username).isEqualTo("msfjarvis")
|
||||
}
|
||||
|
||||
test("get posts by single tag") {
|
||||
var tags = Tags()
|
||||
tags.addTag("meta")
|
||||
val posts = api.getPostsByTags(tags, 1)
|
||||
posts.shouldBeTypeOf<Success<List<LobstersPost>>>()
|
||||
posts.value shouldHaveSize 25
|
||||
posts.value[0].tags.contains("meta")
|
||||
}
|
||||
|
||||
test("get posts by multiple tags") {
|
||||
var tags = Tags()
|
||||
tags.addTag("programming")
|
||||
tags.addTag("rust")
|
||||
val posts = api.getPostsByTags(tags, 1)
|
||||
posts.shouldBeTypeOf<Success<List<LobstersPost>>>()
|
||||
posts.value shouldHaveSize 25
|
||||
posts.value[0].tags.contains("programming") or posts.value[0].tags.contains("rust")
|
||||
}
|
||||
@Test
|
||||
fun `retrieve CSRF token`() = runTest {
|
||||
val token = api.getCSRFToken()
|
||||
assertIs<Success<CSRFToken>>(token)
|
||||
assertThat(token.value.value)
|
||||
.isEqualTo(
|
||||
"OZWykgFemPVeOSNmB53-ccKXe458X7xCInO1-qzFU6nk_9RCSrSQqS9OPmA5_pyy8qD3IYAIZ7XfAM3gdhJpkQ"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,16 @@ package dev.msfjarvis.claw.api
|
||||
import com.slack.eithernet.ApiResult.Companion.success
|
||||
import com.slack.eithernet.test.EitherNetController
|
||||
import com.slack.eithernet.test.enqueue
|
||||
import dev.msfjarvis.claw.api.converters.CSRFTokenConverter
|
||||
import dev.msfjarvis.claw.model.LobstersPost
|
||||
import dev.msfjarvis.claw.model.LobstersPostDetails
|
||||
import dev.msfjarvis.claw.model.User
|
||||
import dev.msfjarvis.claw.util.TestUtils.getJson
|
||||
import dev.msfjarvis.claw.util.TestUtils.getResource
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNamingStrategy
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.ResponseBody
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
class ApiWrapper(controller: EitherNetController<LobstersApi>) {
|
||||
@@ -24,13 +26,10 @@ class ApiWrapper(controller: EitherNetController<LobstersApi>) {
|
||||
ignoreUnknownKeys = true
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
}
|
||||
private val hottest: List<LobstersPost> = json.decodeFromString(getJson("hottest.json"))
|
||||
private val hottest: List<LobstersPost> = json.decodeFromString(getResource("hottest.json"))
|
||||
private val postDetails: LobstersPostDetails =
|
||||
json.decodeFromString(getJson("post_details_tdfoqh.json"))
|
||||
private val user: User = json.decodeFromString(getJson("msfjarvis.json"))
|
||||
private val metaPosts: List<LobstersPost> = json.decodeFromString(getJson("meta.json"))
|
||||
private val programmingRustPosts: List<LobstersPost> =
|
||||
json.decodeFromString(getJson("programming_rust.json"))
|
||||
json.decodeFromString(getResource("post_details_tdfoqh.json"))
|
||||
private val user: User = json.decodeFromString(getResource("msfjarvis.json"))
|
||||
|
||||
val api = controller.api
|
||||
|
||||
@@ -39,7 +38,12 @@ class ApiWrapper(controller: EitherNetController<LobstersApi>) {
|
||||
controller.enqueue(LobstersApi::getHottestPosts) { success(hottest) }
|
||||
controller.enqueue(LobstersApi::getPostDetails) { success(postDetails) }
|
||||
controller.enqueue(LobstersApi::getUser) { success(user) }
|
||||
controller.enqueue(LobstersApi::getPostsByTags) { success(metaPosts) }
|
||||
controller.enqueue(LobstersApi::getPostsByTags) { success(programmingRustPosts) }
|
||||
controller.enqueue(LobstersApi::getCSRFToken) {
|
||||
success(
|
||||
CSRFTokenConverter.convert(
|
||||
ResponseBody.create(MediaType.get("text/html"), getResource("csrf_page.html"))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright © 2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.api
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.slack.eithernet.ApiResult
|
||||
import com.slack.eithernet.test.newEitherNetController
|
||||
import dev.msfjarvis.claw.model.LobstersPost
|
||||
import dev.msfjarvis.claw.model.User
|
||||
import dev.msfjarvis.claw.util.TestUtils.assertIs
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SearchApiTest {
|
||||
private val wrapper = SearchApiWrapper(newEitherNetController())
|
||||
private val api
|
||||
get() = wrapper.api
|
||||
|
||||
@Test
|
||||
fun `search is able to parse HTML`() = runTest {
|
||||
val posts = api.searchPosts("ChatGPT", 1)
|
||||
assertIs<ApiResult.Success<List<LobstersPost>>>(posts)
|
||||
assertThat(posts.value).containsAtLeastElementsIn(results).inOrder()
|
||||
assertThat(posts.value).hasSize(20)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private val results =
|
||||
listOf(
|
||||
LobstersPost(
|
||||
shortId = "gjlsdg",
|
||||
title = "ChatGPT visits the Emacs doctor",
|
||||
url = "https://xenodium.com/chatgpt-visits-the-emacs-doctor/",
|
||||
createdAt = "",
|
||||
commentCount = 3,
|
||||
commentsUrl = "https://lobste.rs/s/gjlsdg/chatgpt_visits_emacs_doctor",
|
||||
submitter = User("xenodium", "", null, "/avatars/xenodium-16.png", ""),
|
||||
tags = listOf("ai", "emacs"),
|
||||
description = "",
|
||||
),
|
||||
LobstersPost(
|
||||
shortId = "astcqf",
|
||||
title =
|
||||
"Implementing a question-answering system for PDF documents using ChatGPT and Redis",
|
||||
url = "https://mstack.nl/blog/20230623-chatgpt-question-pdf-document/",
|
||||
createdAt = "",
|
||||
commentCount = 0,
|
||||
commentsUrl = "https://lobste.rs/s/astcqf/implementing_question_answering_system",
|
||||
submitter = User("asteroid", "", null, "/avatars/asteroid-16.png", ""),
|
||||
tags = listOf("ai"),
|
||||
description = "",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright © 2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.api
|
||||
|
||||
import com.slack.eithernet.ApiResult.Companion.success
|
||||
import com.slack.eithernet.test.EitherNetController
|
||||
import com.slack.eithernet.test.enqueue
|
||||
import dev.msfjarvis.claw.api.converters.SearchConverter
|
||||
import dev.msfjarvis.claw.util.TestUtils.getResource
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.ResponseBody
|
||||
|
||||
class SearchApiWrapper(controller: EitherNetController<LobstersSearchApi>) {
|
||||
val api = controller.api
|
||||
|
||||
init {
|
||||
controller.enqueue(LobstersSearchApi::searchPosts) {
|
||||
success(
|
||||
SearchConverter.convert(
|
||||
ResponseBody.create(MediaType.get("text/html"), getResource("search_chatgpt_page.html"))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,27 @@
|
||||
/*
|
||||
* Copyright © 2021-2022 Harsh Shandilya.
|
||||
* Copyright © 2021-2023 Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.util
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import java.io.File
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.contract
|
||||
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
object TestUtils {
|
||||
fun getJson(path: String): String {
|
||||
fun getResource(path: String): String {
|
||||
// Load the JSON response
|
||||
val uri = javaClass.classLoader.getResource(path)
|
||||
val uri = javaClass.classLoader!!.getResource(path)
|
||||
val file = File(uri.path)
|
||||
return String(file.readBytes())
|
||||
}
|
||||
|
||||
inline fun <reified T> assertIs(value: Any?) {
|
||||
contract { returns() implies (value is T) }
|
||||
assertThat(value).isInstanceOf(T::class.java)
|
||||
}
|
||||
}
|
||||
|
||||