Compare commits

..

98 Commits
ui ... v7.0-b2

Author SHA1 Message Date
Andrius
d62ecdc177 Added translation using Weblate (Lithuanian)
Added translation using Weblate (Lithuanian)

Co-authored-by: Andrius <sndriuss@gmail.com>
2024-04-29 19:09:07 +03:00
Infy's Tagalog Translations
77cd7dda5f Translated using Weblate (Filipino)
Currently translated at 100.0% (635 of 635 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-04-29 19:09:07 +03:00
Scrambled777
bd7099e97c Translated using Weblate (Hindi)
Currently translated at 100.0% (635 of 635 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-04-29 19:09:07 +03:00
gekka
b9457a35b9 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (635 of 635 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-04-29 19:09:07 +03:00
Celysia
53918ddddd Translated using Weblate (Indonesian)
Currently translated at 91.6% (582 of 635 strings)

Co-authored-by: Celysia <celysiasyantik@neko2.net>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-04-29 19:09:07 +03:00
Макар Разин
84f0da0871 Translated using Weblate (Polish)
Currently translated at 99.8% (634 of 635 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (635 of 635 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (635 of 635 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-04-29 19:09:07 +03:00
Koitharu
11c2e2e3bc Translated using Weblate (Russian)
Currently translated at 100.0% (635 of 635 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-04-29 19:09:07 +03:00
Oğuz Ersen
622c8d1c18 Translated using Weblate (Turkish)
Currently translated at 100.0% (635 of 635 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-04-29 19:09:07 +03:00
gallegonovato
10ffae7d4e Translated using Weblate (Spanish)
Currently translated at 99.8% (634 of 635 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-04-29 19:09:07 +03:00
Макар Разин
15b48fd902 Translated using Weblate (Belarusian)
Currently translated at 100.0% (635 of 635 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translation: Kotatsu/Strings
2024-04-29 19:09:07 +03:00
Koitharu
e2d7f2890d Tune ui 2024-04-29 19:06:35 +03:00
Koitharu
af510beb7b Detect CloudFlare blocks 2024-04-29 12:27:29 +03:00
Koitharu
8cf0203b42 Add "Open in browser" action in lists 2024-04-29 11:32:42 +03:00
Koitharu
ea4a81c6ec Fixes 2024-04-29 11:14:28 +03:00
Koitharu
63b53d2244 Non-modal bottom sheet on details activity 2024-04-29 11:00:56 +03:00
Koitharu
aba6b64074 Scrobbling fixes 2024-04-27 16:41:10 +03:00
Koitharu
324bfc733b Fix bookmark action in reader 2024-04-25 08:56:37 +03:00
Koitharu
fcfb3c9808 Fix feed layoutmanager 2024-04-24 11:26:42 +03:00
Koitharu
4ab77064ee Merge branch 'devel' of https://hosted.weblate.org/git/kotatsu/strings into devel 2024-04-24 10:53:18 +03:00
N. Hao
ca2182d588 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (631 of 631 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
2024-04-24 09:48:15 +02:00
Scrambled777
5ba410acd5 Translated using Weblate (Hindi)
Currently translated at 100.0% (631 of 631 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
2024-04-24 09:48:15 +02:00
gekka
06382649c4 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (631 of 631 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
2024-04-24 09:48:13 +02:00
Oğuz Ersen
4f50e905af Translated using Weblate (Turkish)
Currently translated at 100.0% (631 of 631 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
2024-04-24 09:48:12 +02:00
Oğuz Ersen
822cdab6ee Translated using Weblate (English)
Currently translated at 100.0% (631 of 631 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
2024-04-24 09:48:11 +02:00
Koitharu
8fad307c9a Fix margins for pinned navbar 2024-04-24 10:47:58 +03:00
Koitharu
daa545f3db Add authors suggestion and update search suggestion ui 2024-04-24 10:41:44 +03:00
Koitharu
56892aea3c Fix alert dialog style 2024-04-24 10:07:19 +03:00
Koitharu
73e768def0 Configure search suggestions 2024-04-24 09:31:40 +03:00
Koitharu
19da2267d6 Pin navigation ui option #851 2024-04-23 11:35:44 +03:00
Koitharu
3affec0f88 Group tracker notifications 2024-04-23 11:10:29 +03:00
Koitharu
448c688629 Tracker frequency preference 2024-04-23 09:48:39 +03:00
Koitharu
fc2ab3f795 Bring back reader slider 2024-04-23 09:23:19 +03:00
Koitharu
e520e695f9 Fix list selector 2024-04-23 07:35:54 +03:00
Koitharu
b34f438430 Remove redundant translations 2024-04-21 18:50:26 +03:00
Koitharu
72bfe15728 Merge branch 'devel' of github.com:KotatsuApp/Kotatsu into devel 2024-04-21 18:31:39 +03:00
maryush
60198bc878 Translated using Weblate (Polish)
Currently translated at 100.0% (625 of 625 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-04-21 17:45:48 +03:00
Anon
631c09badb Translated using Weblate (Serbian)
Currently translated at 100.0% (625 of 625 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-04-21 17:45:48 +03:00
abc0922001
2bf6eb6f0e Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (625 of 625 strings)

Co-authored-by: abc0922001 <abc0922001@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2024-04-21 17:45:48 +03:00
gallegonovato
1ee8b65ff7 Translated using Weblate (Spanish)
Currently translated at 100.0% (625 of 625 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-04-21 17:45:48 +03:00
N. Hao
d367750331 Translated using Weblate (Vietnamese)
Currently translated at 100.0% (625 of 625 strings)

Translated using Weblate (Vietnamese)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: N. Hao <nguyenviethao2002@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/vi/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/vi/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-04-21 17:45:48 +03:00
Akhil Raj
6d1bc5b1fd Translated using Weblate (Malayalam)
Currently translated at 1.7% (11 of 625 strings)

Added translation using Weblate (Malayalam)

Co-authored-by: Akhil Raj <akhilakae07@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ml/
Translation: Kotatsu/Strings
2024-04-21 17:45:48 +03:00
Infy's Tagalog Translations
45771adef0 Translated using Weblate (Filipino)
Currently translated at 100.0% (650 of 650 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-04-21 17:45:48 +03:00
Scrambled777
d91f613c28 Translated using Weblate (Hindi)
Currently translated at 100.0% (650 of 650 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-04-21 17:45:48 +03:00
gekka
988dd767d8 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (625 of 625 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (650 of 650 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-04-21 17:45:48 +03:00
Oğuz Ersen
d715c175b8 Translated using Weblate (Turkish)
Currently translated at 100.0% (650 of 650 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-04-21 17:45:48 +03:00
Макар Разин
a114605be1 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (625 of 625 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (625 of 625 strings)

Translated using Weblate (Polish)

Currently translated at 99.5% (647 of 650 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (650 of 650 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (650 of 650 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (650 of 650 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-04-21 17:45:48 +03:00
Renn
f7a9e2ef89 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (648 of 648 strings)

Co-authored-by: Renn <mcperenan134@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-04-21 17:45:48 +03:00
Koitharu
2aa3133c52 Update parsers and fix build 2024-04-21 17:44:39 +03:00
Koitharu
2f15ea213d Fix notifications 2024-04-21 15:11:01 +03:00
Koitharu
19a3f14190 Fix chapters sheet interaction 2024-04-21 14:38:51 +03:00
Koitharu
fb716d300e Chapters list grouping 2024-04-21 10:01:57 +03:00
Koitharu
1fe5095654 Update dependencies 2024-04-21 08:14:26 +03:00
Koitharu
820d3f2413 Update placeholders 2024-04-17 19:16:23 +03:00
Koitharu
34903fc951 Hide search bar when ActionMode is active 2024-04-17 08:56:05 +03:00
Koitharu
7ec2e0c5cc Tracker improvements 2024-04-17 08:53:41 +03:00
Koitharu
846c346a86 Add unread field to feed items 2024-04-16 10:03:01 +03:00
Koitharu
f685ed6932 Fix track worker scheduling 2024-04-16 07:45:52 +03:00
Koitharu
98b8ec5c89 Remove unused resources 2024-04-16 07:45:14 +03:00
Koitharu
0e20bf4afe Gaps between pages in webtoon mode #833 2024-04-15 08:19:26 +03:00
Zakhar Timoshenko
fe588c08e2 UI adjust part 2 2024-04-14 15:13:39 +03:00
Koitharu
3ee6ac605d Fix current chapter download #840 2024-04-14 08:55:35 +03:00
Koitharu
535feb424c UI fixes 2024-04-14 08:55:34 +03:00
Макар Разин
2cca696808 Translated using Weblate (Ukrainian)
Currently translated at 99.0% (641 of 647 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (647 of 647 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-04-14 08:44:18 +03:00
abc0922001
b5ea0ec7fa Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (647 of 647 strings)

Co-authored-by: abc0922001 <abc0922001@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2024-04-14 08:44:18 +03:00
Raphael Terrance Fernandez
1e3d2595cf Translated using Weblate (Malayalam)
Currently translated at 0.0% (0 of 9 strings)

Co-authored-by: Raphael Terrance Fernandez <raphaeltfernandez@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ml/
Translation: Kotatsu/plurals
2024-04-14 08:44:18 +03:00
kaajjo
960b960726 Translated using Weblate (Russian)
Currently translated at 99.5% (643 of 646 strings)

Co-authored-by: kaajjo <claymanoff@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-04-14 08:44:18 +03:00
Blackiezin
cd29760836 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (646 of 646 strings)

Translated using Weblate (Portuguese)

Currently translated at 99.5% (643 of 646 strings)

Co-authored-by: Blackiezin <mcperenan134@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-04-14 08:44:18 +03:00
gallegonovato
27a2883f0a Translated using Weblate (Spanish)
Currently translated at 100.0% (647 of 647 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (646 of 646 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-04-14 08:44:18 +03:00
Boum Boum
326bca2273 Translated using Weblate (French)
Currently translated at 97.9% (633 of 646 strings)

Translated using Weblate (French)

Currently translated at 97.3% (629 of 646 strings)

Co-authored-by: Boum Boum <bboum184@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2024-04-14 08:44:18 +03:00
bedo david
b32487fcb8 Translated using Weblate (Hungarian)
Currently translated at 99.8% (643 of 644 strings)

Co-authored-by: bedo david <bedo.david7676@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hu/
Translation: Kotatsu/Strings
2024-04-14 08:44:18 +03:00
Eduardo Malaspina
105bdff9ab Translated using Weblate (Spanish)
Currently translated at 99.5% (641 of 644 strings)

Co-authored-by: Eduardo Malaspina <vaio0@swismail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-04-14 08:44:18 +03:00
Roger VC
6b767523a9 Translated using Weblate (Catalan)
Currently translated at 13.1% (85 of 644 strings)

Added translation using Weblate (Catalan)

Added translation using Weblate (Catalan)

Co-authored-by: Roger VC <rogervilarasau@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ca/
Translation: Kotatsu/Strings
2024-04-14 08:44:18 +03:00
Milena Vitoria Carvalho Barbosa
396050c051 Translated using Weblate (Portuguese)
Currently translated at 94.0% (603 of 641 strings)

Co-authored-by: Milena Vitoria Carvalho Barbosa <mv8365597@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-04-14 08:44:18 +03:00
Infy's Tagalog Translations
c32d1877ff Translated using Weblate (Filipino)
Currently translated at 100.0% (648 of 648 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (647 of 647 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (646 of 646 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (644 of 644 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (641 of 641 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (639 of 639 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2024-04-14 08:44:18 +03:00
Anon
df78d9bf4c Translated using Weblate (Serbian)
Currently translated at 100.0% (646 of 646 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (644 of 644 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (639 of 639 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-04-14 08:44:18 +03:00
Макар Разин
cc3bea3b2c Translated using Weblate (Belarusian)
Currently translated at 100.0% (647 of 647 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (639 of 639 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (639 of 639 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (639 of 639 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-04-14 08:44:18 +03:00
Scrambled777
87aa38b4e8 Translated using Weblate (Hindi)
Currently translated at 100.0% (648 of 648 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (647 of 647 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (646 of 646 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (644 of 644 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (641 of 641 strings)

Translated using Weblate (Hindi)

Currently translated at 100.0% (639 of 639 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-04-14 08:44:18 +03:00
maryush
ee0215511a Translated using Weblate (Polish)
Currently translated at 100.0% (646 of 646 strings)

Translated using Weblate (Polish)

Currently translated at 100.0% (639 of 639 strings)

Co-authored-by: maryush <maryush@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pl/
Translation: Kotatsu/Strings
2024-04-14 08:44:18 +03:00
gekka
bd0056394e Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (648 of 648 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (647 of 647 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (646 of 646 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (646 of 646 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (644 of 644 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (641 of 641 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (641 of 641 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (639 of 639 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-04-14 08:44:18 +03:00
Oğuz Ersen
76ea7ab046 Translated using Weblate (Turkish)
Currently translated at 100.0% (648 of 648 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (647 of 647 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (646 of 646 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (644 of 644 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (641 of 641 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (639 of 639 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-04-14 08:44:18 +03:00
gallegonovato
3d7ea1637f Translated using Weblate (Spanish)
Currently translated at 100.0% (641 of 641 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (639 of 639 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-04-14 08:44:18 +03:00
Koitharu
4b30905f9c Tracker improvements 2024-04-13 13:57:11 +03:00
Koitharu
bddb8431c5 Update reader ui 2024-04-13 13:56:47 +03:00
Koitharu
61e02dd827 Update incognito indicator and PreviewFragment 2024-04-13 11:26:31 +03:00
Koitharu
ff4eac8269 Improve updated manga screen 2024-04-13 09:13:07 +03:00
Koitharu
32eba77639 Add Updated navigation section 2024-04-11 18:56:47 +03:00
Koitharu
09eb82ca2e Move import to service 2024-04-11 18:01:28 +03:00
Koitharu
4d7ff5f6cc Tracker debug info 2024-04-11 10:39:39 +03:00
Koitharu
59dd53c025 Cleanup 2024-04-11 08:12:29 +03:00
Koitharu
c98d7561b8 Update suggestions section on explore screen 2024-04-10 14:14:21 +03:00
Koitharu
5c8157b81f Update DotsIndicator style 2024-04-09 08:22:13 +03:00
Koitharu
7f5ff1ab14 Update recommendations item in explore section 2024-04-08 19:26:42 +03:00
Koitharu
018c84b6af Add Last used default tab in details 2024-04-08 17:54:34 +03:00
Koitharu
b95174727a Update details activity 2024-04-08 17:32:24 +03:00
Koitharu
0aec2359cf Merge branch 'ui' of github.com:KotatsuApp/Kotatsu into devel 2024-04-08 10:41:01 +03:00
Koitharu
62bd5008fd ProgressButton fixes 2024-04-08 10:40:48 +03:00
Koitharu
89dd7beafe Updated sort order for history and favorites 2024-04-08 10:13:54 +03:00
Koitharu
cecf3617af Adaptive tracker interval 2024-04-08 10:13:53 +03:00
309 changed files with 5434 additions and 6819 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 34
versionCode = 633
versionName = '6.8.3'
versionCode = 636
versionName = '7.0-b2'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@@ -82,7 +82,7 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:44ea9fe709') {
implementation('com.github.KotatsuApp:kotatsu-parsers:a245574dee') {
exclude group: 'org.json', module: 'json'
}
@@ -91,8 +91,8 @@ dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.activity:activity-ktx:1.8.2'
implementation 'androidx.core:core-ktx:1.13.0'
implementation 'androidx.activity:activity-ktx:1.9.0'
implementation 'androidx.fragment:fragment-ktx:1.6.2'
implementation 'androidx.collection:collection-ktx:1.4.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
@@ -104,7 +104,7 @@ dependencies {
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.12.0-beta01'
implementation 'com.google.android.material:material:1.12.0-rc01'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.7.0'
implementation 'androidx.webkit:webkit:1.10.0'
@@ -145,7 +145,7 @@ dependencies {
implementation 'org.conscrypt:conscrypt-android:2.5.2'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.13'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.14'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20240303'

View File

@@ -174,12 +174,12 @@ class TrackerTest {
assertEquals(3, repository.getNewChaptersCount(mangaFirst.id))
var chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex - 1) }
repository.syncWithHistory(mangaFull, chapter.id)
tracker.syncWithHistory(mangaFull, chapter.id)
assertEquals(1, repository.getNewChaptersCount(mangaFirst.id))
chapter = requireNotNull(mangaFull.chapters).run { get(lastIndex) }
repository.syncWithHistory(mangaFull, chapter.id)
tracker.syncWithHistory(mangaFull, chapter.id)
assertEquals(0, repository.getNewChaptersCount(mangaFirst.id))

View File

@@ -0,0 +1,12 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".tracker.ui.debug.TrackerDebugActivity"
android:label="@string/check_for_new_chapters" />
</application>
</manifest>

View File

@@ -0,0 +1,72 @@
package org.koitharu.kotatsu.tracker.ui.debug
import android.graphics.Color
import android.text.format.DateUtils
import androidx.core.content.ContextCompat
import androidx.core.text.bold
import androidx.core.text.buildSpannedString
import androidx.core.text.color
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemTrackDebugBinding
import org.koitharu.kotatsu.tracker.data.TrackEntity
import com.google.android.material.R as materialR
fun trackDebugAD(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
clickListener: OnListItemClickListener<TrackDebugItem>,
) = adapterDelegateViewBinding<TrackDebugItem, TrackDebugItem, ItemTrackDebugBinding>(
{ layoutInflater, parent -> ItemTrackDebugBinding.inflate(layoutInflater, parent, false) },
) {
val indicatorNew = ContextCompat.getDrawable(context, R.drawable.ic_new)
itemView.setOnClickListener { v ->
clickListener.onItemClick(item, v)
}
bind {
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
defaultPlaceholders(context)
allowRgb565(true)
source(item.manga.source)
enqueueWith(coil)
}
binding.textViewTitle.text = item.manga.title
binding.textViewSummary.text = buildSpannedString {
item.lastCheckTime?.let {
append(
DateUtils.getRelativeDateTimeString(
context,
it.toEpochMilli(),
DateUtils.MINUTE_IN_MILLIS,
DateUtils.WEEK_IN_MILLIS,
0,
),
)
}
if (item.lastResult == TrackEntity.RESULT_FAILED) {
append(" - ")
bold {
color(context.getThemeColor(materialR.attr.colorError, Color.RED)) {
append(getString(R.string.error))
}
}
}
}
binding.textViewTitle.drawableStart = if (item.newChapters > 0) {
indicatorNew
} else {
null
}
}
}

View File

@@ -0,0 +1,19 @@
package org.koitharu.kotatsu.tracker.ui.debug
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import java.time.Instant
data class TrackDebugItem(
val manga: Manga,
val lastChapterId: Long,
val newChapters: Int,
val lastCheckTime: Instant?,
val lastChapterDate: Instant?,
val lastResult: Int,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is TrackDebugItem && other.manga.id == manga.id
}
}

View File

@@ -0,0 +1,57 @@
package org.koitharu.kotatsu.tracker.ui.debug
import android.os.Bundle
import android.view.View
import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.ActivityTrackerDebugBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import javax.inject.Inject
@AndroidEntryPoint
class TrackerDebugActivity : BaseActivity<ActivityTrackerDebugBinding>(), OnListItemClickListener<TrackDebugItem> {
@Inject
lateinit var coil: ImageLoader
private val viewModel by viewModels<TrackerDebugViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityTrackerDebugBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val tracksAdapter = BaseListAdapter<TrackDebugItem>()
.addDelegate(ListItemType.FEED, trackDebugAD(this, coil, this))
with(viewBinding.recyclerView) {
adapter = tracksAdapter
addItemDecoration(TypedListSpacingDecoration(context, false))
}
viewModel.content.observe(this, tracksAdapter)
}
override fun onWindowInsetsChanged(insets: Insets) {
val rv = viewBinding.recyclerView
rv.updatePadding(
left = insets.left + rv.paddingTop,
right = insets.right + rv.paddingTop,
bottom = insets.bottom,
)
viewBinding.toolbar.updatePadding(
left = insets.left,
right = insets.right,
)
}
override fun onItemClick(item: TrackDebugItem, view: View) {
startActivity(DetailsActivity.newIntent(this, item.manga))
}
}

View File

@@ -0,0 +1,36 @@
package org.koitharu.kotatsu.tracker.ui.debug
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.toInstantOrNull
import org.koitharu.kotatsu.tracker.data.TrackWithManga
import javax.inject.Inject
@HiltViewModel
class TrackerDebugViewModel @Inject constructor(
private val db: MangaDatabase
) : BaseViewModel() {
val content = db.getTracksDao().observeAll()
.map { it.toUiList() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
private fun List<TrackWithManga>.toUiList(): List<TrackDebugItem> = map {
TrackDebugItem(
manga = it.manga.toManga(emptySet()),
lastChapterId = it.track.lastChapterId,
newChapters = it.track.newChapters,
lastCheckTime = it.track.lastCheckTime.toInstantOrNull(),
lastChapterDate = it.track.lastChapterDate.toInstantOrNull(),
lastResult = it.track.lastResult,
)
}
}

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:id="@+id/collapsingToolbarLayout"
android:layout_width="match_parent"
android:layout_height="?attr/collapsingToolbarLayoutMediumSize"
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap"
app:toolbarId="@id/toolbar">
<com.google.android.material.appbar.MaterialToolbar
android:id="@id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_collapseMode="pin" />
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:padding="@dimen/list_spacing_normal"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
tools:listitem="@layout/item_track_debug" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -4,7 +4,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="72dp"
android:background="@drawable/list_selector"
android:clipChildren="false">
@@ -14,7 +14,9 @@
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
@@ -26,42 +28,29 @@
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="8dp"
android:drawablePadding="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyLarge"
app:layout_constraintBottom_toTopOf="@+id/textView_subtitle"
app:layout_constraintEnd_toStartOf="@id/button_more"
android:textAppearance="?attr/textAppearanceTitleSmall"
app:layout_constraintBottom_toTopOf="@+id/textView_summary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
app:layout_constraintTop_toTopOf="@+id/imageView_cover"
tools:text="@tools:sample/lorem" />
<TextView
android:id="@+id/textView_subtitle"
android:id="@+id/textView_summary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textAppearance="?attr/textAppearanceBodySmall"
app:layout_constraintBottom_toBottomOf="@+id/imageView_cover"
app:layout_constraintEnd_toStartOf="@id/button_more"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
app:layout_constraintTop_toBottomOf="@+id/textView_title"
tools:text="@tools:sample/lorem/random" />
<Button
android:id="@+id/button_more"
style="@style/Widget.Kotatsu.ExploreButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:gravity="center"
android:minWidth="120dp"
android:text="@string/more"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -8,4 +8,9 @@
android:title="@string/leak_canary_display_activity_label"
app:showAsAction="never" />
</menu>
<item
android:id="@id/action_tracker"
android:title="@string/check_for_new_chapters"
app:showAsAction="never" />
</menu>

View File

@@ -94,34 +94,6 @@
<data android:host="kotatsu.app" />
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.details.ui.DetailsActivity2"
android:exported="true">
<intent-filter>
<action android:name="${applicationId}.action.VIEW_MANGA" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="kotatsu.app" />
<data android:path="/manga" />
</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="kotatsu" />
<data android:host="manga" />
<data android:host="kotatsu.app" />
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.reader.ui.ReaderActivity"
android:exported="true">
@@ -281,6 +253,9 @@
<service
android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService"
android:foregroundServiceType="dataSync" />
<service
android:name="org.koitharu.kotatsu.local.ui.ImportService"
android:foregroundServiceType="dataSync" />
<service
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" />

View File

@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
@@ -79,9 +80,7 @@ fun alternativeAD(
}
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
size(CoverSizeResolver(binding.imageViewCover))
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
defaultPlaceholders(context)
transformations(TrimTransformation())
allowRgb565(true)
tag(item.manga)

View File

@@ -30,7 +30,7 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
import org.koitharu.kotatsu.list.ui.GridSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
@@ -86,7 +86,7 @@ class BookmarksFragment :
val spanSizeLookup = SpanSizeLookup()
with(binding.recyclerView) {
setHasFixedSize(true)
val spanResolver = MangaListSpanResolver(resources)
val spanResolver = GridSpanResolver(resources)
addItemDecoration(TypedListSpacingDecoration(context, false))
adapter = bookmarksAdapter
addOnLayoutChangeListener(spanResolver)

View File

@@ -3,12 +3,12 @@ package org.koitharu.kotatsu.bookmarks.ui.adapter
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
@@ -29,9 +29,7 @@ fun bookmarkListAD(
bind {
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
size(CoverSizeResolver(binding.imageViewThumb))
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
defaultPlaceholders(context)
allowRgb565(true)
tag(item)
decodeRegion(item.scroll)

View File

@@ -3,12 +3,12 @@ package org.koitharu.kotatsu.bookmarks.ui.sheet
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
@@ -30,9 +30,7 @@ fun bookmarkLargeAD(
bind {
binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run {
size(CoverSizeResolver(binding.imageViewThumb))
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
defaultPlaceholders(context)
allowRgb565(true)
tag(item)
decodeRegion(item.scroll)

View File

@@ -1,170 +0,0 @@
package org.koitharu.kotatsu.bookmarks.ui.sheet
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.plus
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetPagesBinding
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
import javax.inject.Inject
import kotlin.math.roundToInt
@Deprecated("")
@AndroidEntryPoint
class BookmarksSheet :
BaseAdaptiveSheet<SheetPagesBinding>(),
AdaptiveSheetCallback,
OnListItemClickListener<Bookmark> {
private val viewModel by viewModels<BookmarksSheetViewModel>()
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var settings: AppSettings
private var bookmarksAdapter: BookmarksAdapter? = null
private var spanResolver: MangaListSpanResolver? = null
private val spanSizeLookup = SpanSizeLookup()
private val listCommitCallback = Runnable {
spanSizeLookup.invalidateCache()
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetPagesBinding {
return SheetPagesBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetPagesBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
addSheetCallback(this)
spanResolver = MangaListSpanResolver(binding.root.resources)
bookmarksAdapter = BookmarksAdapter(
coil = coil,
lifecycleOwner = viewLifecycleOwner,
clickListener = this@BookmarksSheet,
headerClickListener = null,
)
viewBinding?.headerBar?.setTitle(R.string.bookmarks)
with(binding.recyclerView) {
addItemDecoration(TypedListSpacingDecoration(context, false))
adapter = bookmarksAdapter
addOnLayoutChangeListener(spanResolver)
spanResolver?.setGridSize(settings.gridSize / 100f, this)
(layoutManager as GridLayoutManager).spanSizeLookup = spanSizeLookup
}
viewModel.content.observe(viewLifecycleOwner, ::onThumbnailsChanged)
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
}
override fun onDestroyView() {
spanResolver = null
bookmarksAdapter = null
spanSizeLookup.invalidateCache()
super.onDestroyView()
}
override fun onItemClick(item: Bookmark, view: View) {
val listener = (parentFragment as? OnPageSelectListener) ?: (activity as? OnPageSelectListener)
if (listener != null) {
listener.onPageSelected(ReaderPage(item.toMangaPage(), item.page, item.chapterId))
} else {
val intent = IntentBuilder(view.context)
.manga(viewModel.manga)
.bookmark(item)
.incognito(true)
.build()
startActivity(intent)
}
dismiss()
}
override fun onStateChanged(sheet: View, newState: Int) {
viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED
}
private fun onThumbnailsChanged(list: List<ListModel>) {
val adapter = bookmarksAdapter ?: return
if (adapter.itemCount == 0) {
var position = list.indexOfFirst { it is PageThumbnail && it.isCurrent }
if (position > 0) {
val spanCount = spanResolver?.spanCount ?: 0
val offset = if (position > spanCount + 1) {
(resources.getDimensionPixelSize(R.dimen.manga_list_details_item_height) * 0.6).roundToInt()
} else {
position = 0
0
}
val scrollCallback = RecyclerViewScrollCallback(requireViewBinding().recyclerView, position, offset)
adapter.setItems(list, listCommitCallback + scrollCallback)
} else {
adapter.setItems(list, listCommitCallback)
}
} else {
adapter.setItems(list, listCommitCallback)
}
}
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {
init {
isSpanIndexCacheEnabled = true
isSpanGroupIndexCacheEnabled = true
}
override fun getSpanSize(position: Int): Int {
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1
return when (bookmarksAdapter?.getItemViewType(position)) {
ListItemType.PAGE_THUMB.ordinal -> 1
else -> total
}
}
fun invalidateCache() {
invalidateSpanGroupIndexCache()
invalidateSpanIndexCache()
}
}
companion object {
const val ARG_MANGA = "manga"
private const val TAG = "BookmarksSheet"
fun show(fm: FragmentManager, manga: Manga) {
BookmarksSheet().withArgs(1) {
putParcelable(ARG_MANGA, ParcelableManga(manga))
}.showDistinct(fm, TAG)
}
}
}

View File

@@ -1,56 +0,0 @@
package org.koitharu.kotatsu.bookmarks.ui.sheet
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import javax.inject.Inject
@Deprecated("")
@HiltViewModel
class BookmarksSheetViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory,
bookmarksRepository: BookmarksRepository,
) : BaseViewModel() {
val manga = savedStateHandle.require<ParcelableManga>(BookmarksSheet.ARG_MANGA).manga
private val chaptersLazy = SuspendLazy {
requireNotNull(manga.chapters ?: mangaRepositoryFactory.create(manga.source).getDetails(manga).chapters)
}
val content: StateFlow<List<ListModel>> = bookmarksRepository.observeBookmarks(manga)
.map { mapList(it) }
.withErrorHandling()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingFooter()))
private suspend fun mapList(bookmarks: List<Bookmark>): List<ListModel> {
val chapters = chaptersLazy.get()
val bookmarksMap = bookmarks.groupBy { it.chapterId }
val result = ArrayList<ListModel>(bookmarks.size + bookmarksMap.size)
for (chapter in chapters) {
val b = bookmarksMap[chapter.id]
if (b.isNullOrEmpty()) {
continue
}
result += ListHeader(chapter.name)
result.addAll(b)
}
return result
}
}

View File

@@ -40,6 +40,7 @@ class CaptchaNotifier(
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setDefaults(NotificationCompat.DEFAULT_SOUND)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setGroup(GROUP_CAPTCHA)
.setAutoCancel(true)
.setVisibility(
if (exception.source?.contentType == ContentType.HENTAI) {
@@ -82,5 +83,6 @@ class CaptchaNotifier(
private const val PARAM_IGNORE_CAPTCHA = "ignore_captcha"
private const val CHANNEL_ID = "captcha"
private const val TAG = CHANNEL_ID
private const val GROUP_CAPTCHA = "org.koitharu.kotatsu.CAPTCHA"
}
}

View File

@@ -40,9 +40,9 @@ import org.koitharu.kotatsu.core.parser.favicon.FaviconFetcher
import org.koitharu.kotatsu.core.ui.image.CoilImageGetter
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.util.AcraScreenLogger
import org.koitharu.kotatsu.core.util.IncognitoModeIndicator
import org.koitharu.kotatsu.core.util.ext.connectivityManager
import org.koitharu.kotatsu.core.util.ext.isLowRamDevice
import org.koitharu.kotatsu.details.ui.pager.pages.MangaPageFetcher
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.local.data.CbzFetcher
import org.koitharu.kotatsu.local.data.LocalStorageChanges
@@ -50,7 +50,6 @@ import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.main.domain.CoverRestoreInterceptor
import org.koitharu.kotatsu.main.ui.protect.AppProtectHelper
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.reader.ui.thumbnails.MangaPageFetcher
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.settings.backup.BackupObserver
import org.koitharu.kotatsu.sync.domain.SyncController
@@ -147,12 +146,10 @@ interface AppModule {
fun provideActivityLifecycleCallbacks(
appProtectHelper: AppProtectHelper,
activityRecreationHandle: ActivityRecreationHandle,
incognitoModeIndicator: IncognitoModeIndicator,
acraScreenLogger: AcraScreenLogger,
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
appProtectHelper,
activityRecreationHandle,
incognitoModeIndicator,
acraScreenLogger,
)

View File

@@ -28,6 +28,9 @@ abstract class MangaDao {
@Query("SELECT * FROM manga WHERE source = :source")
abstract suspend fun findAllBySource(source: String): List<MangaWithTags>
@Query("SELECT author FROM manga WHERE author LIKE :query GROUP BY author ORDER BY COUNT(author) DESC LIMIT :limit")
abstract suspend fun findAuthors(query: String, limit: Int): List<String>
@Transaction
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>

View File

@@ -16,9 +16,15 @@ interface TrackLogsDao {
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0")
fun observeAll(limit: Int): Flow<List<TrackLogWithManga>>
@Query("SELECT COUNT(*) FROM track_logs WHERE unread = 1")
fun observeUnreadCount(): Flow<Int>
@Query("DELETE FROM track_logs")
suspend fun clear()
@Query("UPDATE track_logs SET unread = 0 WHERE id = :id")
suspend fun markAsRead(id: Long)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entity: TrackLogEntity): Long

View File

@@ -12,5 +12,7 @@ class Migration19To20 : Migration(19, 20) {
db.execSQL("CREATE TABLE tracks (`manga_id` INTEGER NOT NULL, `last_chapter_id` INTEGER NOT NULL, `chapters_new` INTEGER NOT NULL, `last_check_time` INTEGER NOT NULL, `last_chapter_date` INTEGER NOT NULL, `last_result` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
db.execSQL("INSERT INTO tracks SELECT manga_id, last_chapter_id, chapters_new, last_check AS last_check_time, 0 AS last_chapter_date, 0 AS last_result FROM tracks_bk")
db.execSQL("DROP TABLE tracks_bk")
db.execSQL("ALTER TABLE track_logs ADD COLUMN `unread` INTEGER NOT NULL DEFAULT 0")
}
}

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.core.exceptions
import okhttp3.Headers
import okio.IOException
import org.koitharu.kotatsu.parsers.model.MangaSource
class CloudFlareBlockedException(
val url: String,
val source: MangaSource?,
) : IOException("Blocked by CloudFlare")

View File

@@ -4,6 +4,7 @@ import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.internal.closeQuietly
import org.jsoup.Jsoup
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.net.HttpURLConnection.HTTP_FORBIDDEN
@@ -17,14 +18,23 @@ class CloudFlareInterceptor : Interceptor {
val content = response.body?.let { response.peekBody(Long.MAX_VALUE) }?.byteStream()?.use {
Jsoup.parse(it, Charsets.UTF_8.name(), response.request.url.toString())
} ?: return response
if (content.getElementById("challenge-error-title") != null) {
val hasCaptcha = content.getElementById("challenge-error-title") != null
val isBlocked = content.selectFirst("h2[data-translate=\"blocked_why_headline\"]") != null
if (hasCaptcha || isBlocked) {
val request = response.request
response.closeQuietly()
throw CloudFlareProtectedException(
url = request.url.toString(),
source = request.tag(MangaSource::class.java),
headers = request.headers,
)
if (isBlocked) {
throw CloudFlareBlockedException(
url = request.url.toString(),
source = request.tag(MangaSource::class.java),
)
} else {
throw CloudFlareProtectedException(
url = request.url.toString(),
source = request.tag(MangaSource::class.java),
headers = request.headers,
)
}
}
}
return response

View File

@@ -32,6 +32,7 @@ import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import java.io.File
import java.net.Proxy
import java.util.EnumSet
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@@ -74,6 +75,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isNavLabelsVisible: Boolean
get() = prefs.getBoolean(KEY_NAV_LABELS, true)
val isNavBarPinned: Boolean
get() = prefs.getBoolean(KEY_NAV_PINNED, false)
var gridSize: Int
get() = prefs.getInt(KEY_GRID_SIZE, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
@@ -142,6 +146,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isTrackerWifiOnly: Boolean
get() = prefs.getBoolean(KEY_TRACKER_WIFI_ONLY, false)
val trackerFrequencyFactor: Float
get() = prefs.getString(KEY_TRACKER_FREQUENCY, null)?.toFloatOrNull() ?: 1f
val isTrackerNotificationsEnabled: Boolean
get() = prefs.getBoolean(KEY_TRACKER_NOTIFICATIONS, true)
@@ -172,6 +179,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_HISTORY_GROUPING, true)
set(value) = prefs.edit { putBoolean(KEY_HISTORY_GROUPING, value) }
var isUpdatedGroupingEnabled: Boolean
get() = prefs.getBoolean(KEY_UPDATED_GROUPING, true)
set(value) = prefs.edit { putBoolean(KEY_UPDATED_GROUPING, value) }
var isFeedHeaderVisible: Boolean
get() = prefs.getBoolean(KEY_FEED_HEADER, true)
set(value) = prefs.edit { putBoolean(KEY_FEED_HEADER, value) }
val isReadingIndicatorsEnabled: Boolean
get() = prefs.getBoolean(KEY_READING_INDICATORS, true)
@@ -206,6 +221,13 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_APP_PASSWORD_NUMERIC, false)
set(value) = prefs.edit { putBoolean(KEY_APP_PASSWORD_NUMERIC, value) }
val searchSuggestionTypes: Set<SearchSuggestionType>
get() = prefs.getStringSet(KEY_SEARCH_SUGGESTION_TYPES, null)?.let { stringSet ->
stringSet.mapNotNullTo(EnumSet.noneOf(SearchSuggestionType::class.java)) { x ->
enumValueOf<SearchSuggestionType>(x)
}
} ?: EnumSet.allOf(SearchSuggestionType::class.java)
val isLoggingEnabled: Boolean
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
@@ -230,11 +252,20 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val defaultDetailsTab: Int
get() = if (isPagesTabEnabled) {
prefs.getString(KEY_DETAILS_TAB, null)?.toIntOrNull()?.coerceIn(0, 1) ?: 0
val raw = prefs.getString(KEY_DETAILS_TAB, null)?.toIntOrNull() ?: 0
if (raw == -1) {
lastDetailsTab
} else {
raw
}.coerceIn(0, 2)
} else {
0
}
var lastDetailsTab: Int
get() = prefs.getInt(KEY_DETAILS_LAST_TAB, 0)
set(value) = prefs.edit { putInt(KEY_DETAILS_LAST_TAB, value) }
val isContentPrefetchEnabled: Boolean
get() {
if (isBackgroundNetworkRestricted()) {
@@ -388,9 +419,13 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isRelatedMangaEnabled: Boolean
get() = prefs.getBoolean(KEY_RELATED_MANGA, true)
val isWebtoonZoomEnable: Boolean
val isWebtoonZoomEnabled: Boolean
get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true)
var isWebtoonGapsEnabled: Boolean
get() = prefs.getBoolean(KEY_WEBTOON_GAPS, false)
set(value) = prefs.edit { putBoolean(KEY_WEBTOON_GAPS, value) }
@get:FloatRange(from = 0.0, to = 0.5)
val defaultWebtoonZoomOut: Float
get() = prefs.getInt(KEY_WEBTOON_ZOOM_OUT, 0).coerceIn(0, 50) / 100f
@@ -541,6 +576,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_VOLUME_BUTTONS = "reader_volume_buttons"
const val KEY_TRACKER_ENABLED = "tracker_enabled"
const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi"
const val KEY_TRACKER_FREQUENCY = "tracker_freq"
const val KEY_TRACK_SOURCES = "track_sources"
const val KEY_TRACK_CATEGORIES = "track_categories"
const val KEY_TRACK_WARNING = "track_warning"
@@ -566,6 +602,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_BACKUP_PERIODICAL_OUTPUT = "backup_periodic_output"
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_UPDATED_GROUPING = "updated_grouping"
const val KEY_READING_INDICATORS = "reading_indicators"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
const val KEY_GRID_VIEW_CHAPTERS = "grid_view_chapters"
@@ -600,6 +637,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LOCAL_LIST_ORDER = "local_order"
const val KEY_HISTORY_ORDER = "history_order"
const val KEY_FAVORITES_ORDER = "fav_order"
const val KEY_WEBTOON_GAPS = "webtoon_gaps"
const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
const val KEY_WEBTOON_ZOOM_OUT = "webtoon_zoom_out"
const val KEY_PREFETCH_CONTENT = "prefetch_content"
@@ -626,6 +664,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_RELATED_MANGA = "related_manga"
const val KEY_NAV_MAIN = "nav_main"
const val KEY_NAV_LABELS = "nav_labels"
const val KEY_NAV_PINNED = "nav_pinned"
const val KEY_32BIT_COLOR = "enhanced_colors"
const val KEY_SOURCES_ORDER = "sources_sort_order"
const val KEY_SOURCES_CATALOG = "sources_catalog"
@@ -636,11 +675,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_IGNORE_DOZE = "ignore_dose"
const val KEY_PAGES_TAB = "pages_tab"
const val KEY_DETAILS_TAB = "details_tab"
const val KEY_DETAILS_LAST_TAB = "details_last_tab"
const val KEY_READING_TIME = "reading_time"
const val KEY_PAGES_SAVE_DIR = "pages_dir"
const val KEY_PAGES_SAVE_ASK = "pages_dir_ask"
const val KEY_STATS_ENABLED = "stats_on"
const val KEY_APP_UPDATE = "app_update"
const val KEY_APP_TRANSLATION = "about_app_translation"
const val KEY_FEED_HEADER = "feed_header"
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
}
}

View File

@@ -17,12 +17,13 @@ enum class NavItem(
EXPLORE(R.id.nav_explore, R.string.explore, R.drawable.ic_explore_selector),
SUGGESTIONS(R.id.nav_suggestions, R.string.suggestions, R.drawable.ic_suggestion_selector),
FEED(R.id.nav_feed, R.string.feed, R.drawable.ic_feed_selector),
UPDATED(R.id.nav_updated, R.string.updated, R.drawable.ic_updated_selector),
BOOKMARKS(R.id.nav_bookmarks, R.string.bookmarks, R.drawable.ic_bookmark_selector),
;
fun isAvailable(settings: AppSettings): Boolean = when (this) {
SUGGESTIONS -> settings.isSuggestionsEnabled
FEED -> settings.isTrackerEnabled
UPDATED, FEED -> settings.isTrackerEnabled
else -> true
}
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.core.prefs
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
enum class SearchSuggestionType(
@StringRes val titleResId: Int,
) {
GENRES(R.string.genres),
QUERIES_RECENT(R.string.recent_queries),
QUERIES_SUGGEST(R.string.suggested_queries),
MANGA(R.string.content_type_manga),
SOURCES(R.string.remote_sources),
AUTHORS(R.string.authors),
}

View File

@@ -72,7 +72,7 @@ abstract class BaseActivity<B : ViewBinding> :
onBackPressedDispatcher.addCallback(actionModeDelegate)
}
override fun onNewIntent(intent: Intent?) {
override fun onNewIntent(intent: Intent) {
putDataToExtras(intent)
super.onNewIntent(intent)
}

View File

@@ -6,7 +6,12 @@ import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
@@ -48,4 +53,14 @@ open class BaseListAdapter<T : ListModel> : AsyncListDifferDelegationAdapter<T>(
}
return null
}
fun observeItems(): Flow<List<T>> = callbackFlow {
val listListener = ListListener<T> { _, list ->
trySendBlocking(list)
}
addListListener(listListener)
awaitClose { removeListListener(listListener) }
}.onStart {
emit(items)
}
}

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.core.ui
import android.content.Intent
import androidx.annotation.AnyThread
import androidx.annotation.WorkerThread
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
@@ -39,8 +41,10 @@ abstract class CoroutineIntentService : BaseService() {
}
}
@WorkerThread
protected abstract suspend fun processIntent(startId: Int, intent: Intent)
@AnyThread
protected abstract fun onError(startId: Int, error: Throwable)
private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable ->

View File

@@ -0,0 +1,82 @@
package org.koitharu.kotatsu.core.ui.image
import android.animation.TimeAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.PixelFormat
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import android.view.animation.AccelerateDecelerateInterpolator
import androidx.core.graphics.ColorUtils
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import com.google.android.material.animation.ArgbEvaluatorCompat
import org.koitharu.kotatsu.core.util.ext.animatorDurationScale
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import kotlin.math.abs
import com.google.android.material.R as materialR
class AnimatedPlaceholderDrawable(context: Context) : Drawable(), Animatable, TimeAnimator.TimeListener {
private val colorLow = context.getThemeColor(materialR.attr.colorBackgroundFloating)
private val colorHigh = context.getThemeColor(materialR.attr.colorSurfaceContainer)
private var currentColor: Int = colorLow
private var alpha: Int = 255
private val interpolator = FastOutSlowInInterpolator()
private val period = 2000 * context.animatorDurationScale
private val timeAnimator = TimeAnimator()
init {
timeAnimator.setTimeListener(this)
updateColor()
}
override fun draw(canvas: Canvas) {
if (!isRunning) {
updateColor()
start()
}
canvas.drawColor(currentColor)
}
override fun setAlpha(alpha: Int) {
this.alpha = alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {
throw UnsupportedOperationException("ColorFilter is not supported by PlaceholderDrawable")
}
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int = PixelFormat.OPAQUE
override fun getAlpha(): Int = alpha
override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) {
if (callback != null) {
updateColor()
invalidateSelf()
}
}
override fun start() {
timeAnimator.start()
}
override fun stop() {
timeAnimator.cancel()
}
override fun isRunning(): Boolean = timeAnimator.isStarted
private fun updateColor() {
val ph = period / 2
val fraction = abs((System.currentTimeMillis() % period) - ph) / ph.toFloat()
var color = ArgbEvaluatorCompat.getInstance()
.evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh)
if (alpha != 255) {
color = ColorUtils.setAlphaComponent(color, alpha)
}
currentColor = color
}
}

View File

@@ -0,0 +1,162 @@
package org.koitharu.kotatsu.core.ui.image
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Outline
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.os.Build
import androidx.annotation.ReturnThis
import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.parsers.util.toIntUp
import com.google.android.material.R as materialR
class CardDrawable(
context: Context,
private var corners: Int,
) : Drawable() {
private val cornerSize = context.resources.resolveDp(12f)
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val cornersF = FloatArray(8)
private val boundsF = RectF()
private val color: ColorStateList
private val path = Path()
private var alpha = 255
private var state: IntArray? = null
private var horizontalInset: Int = 0
init {
paint.style = Paint.Style.FILL
color = context.getThemeColorStateList(materialR.attr.colorSurfaceContainerHighest)
?: ColorStateList.valueOf(Color.TRANSPARENT)
setCorners(corners)
updateColor()
}
override fun draw(canvas: Canvas) {
canvas.drawPath(path, paint)
}
override fun setAlpha(alpha: Int) {
this.alpha = alpha
updateColor()
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.colorFilter = colorFilter
}
override fun getColorFilter(): ColorFilter? = paint.colorFilter
override fun getOutline(outline: Outline) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
outline.setPath(path)
} else if (path.isConvex) {
outline.setConvexPath(path)
}
outline.alpha = 1f
}
override fun getPadding(padding: Rect): Boolean {
padding.set(
horizontalInset,
0,
horizontalInset,
0,
)
if (corners or TOP != 0) {
padding.top += cornerSize.toIntUp()
}
if (corners or BOTTOM != 0) {
padding.bottom += cornerSize.toIntUp()
}
return horizontalInset != 0
}
override fun onStateChange(state: IntArray): Boolean {
this.state = state
if (color.isStateful) {
updateColor()
return true
} else {
return false
}
}
@Deprecated("Deprecated in Java")
override fun getOpacity(): Int = PixelFormat.TRANSPARENT
override fun onBoundsChange(bounds: Rect) {
super.onBoundsChange(bounds)
boundsF.set(bounds)
boundsF.inset(horizontalInset.toFloat(), 0f)
path.reset()
path.addRoundRect(boundsF, cornersF, Path.Direction.CW)
path.close()
}
@ReturnThis
fun setCorners(corners: Int): CardDrawable {
this.corners = corners
val topLeft = if (corners and TOP_LEFT == TOP_LEFT) cornerSize else 0f
val topRight = if (corners and TOP_RIGHT == TOP_RIGHT) cornerSize else 0f
val bottomRight = if (corners and BOTTOM_RIGHT == BOTTOM_RIGHT) cornerSize else 0f
val bottomLeft = if (corners and BOTTOM_LEFT == BOTTOM_LEFT) cornerSize else 0f
cornersF[0] = topLeft
cornersF[1] = topLeft
cornersF[2] = topRight
cornersF[3] = topRight
cornersF[4] = bottomRight
cornersF[5] = bottomRight
cornersF[6] = bottomLeft
cornersF[7] = bottomLeft
invalidateSelf()
return this
}
fun setHorizontalInset(inset: Int) {
horizontalInset = inset
invalidateSelf()
}
private fun updateColor() {
paint.color = color.getColorForState(state, color.defaultColor)
paint.alpha = alpha
}
companion object {
const val TOP_LEFT = 1
const val TOP_RIGHT = 2
const val BOTTOM_LEFT = 4
const val BOTTOM_RIGHT = 8
const val LEFT = TOP_LEFT or BOTTOM_LEFT
const val TOP = TOP_LEFT or TOP_RIGHT
const val RIGHT = TOP_RIGHT or BOTTOM_RIGHT
const val BOTTOM = BOTTOM_LEFT or BOTTOM_RIGHT
const val NONE = 0
const val ALL = TOP_LEFT or TOP_RIGHT or BOTTOM_RIGHT or BOTTOM_LEFT
fun from(d: Drawable?): CardDrawable? = when (d) {
null -> null
is CardDrawable -> d
is LayerDrawable -> (0 until d.numberOfLayers).firstNotNullOfOrNull { i ->
from(d.getDrawable(i))
}
else -> null
}
}
}

View File

@@ -29,7 +29,7 @@ class FitHeightGridLayoutManager : GridLayoutManager {
if (orientation == RecyclerView.VERTICAL && child.layoutParams.height == LayoutParams.MATCH_PARENT) {
val parentBottom = height - paddingBottom
val offset = parentBottom - bottom
super.layoutDecoratedWithMargins(child, left, top + offset, right, bottom + offset)
super.layoutDecoratedWithMargins(child, left, top, right, bottom + offset)
} else {
super.layoutDecoratedWithMargins(child, left, top, right, bottom)
}

View File

@@ -29,7 +29,7 @@ class FitHeightLinearLayoutManager : LinearLayoutManager {
if (orientation == RecyclerView.VERTICAL && child.layoutParams.height == LayoutParams.MATCH_PARENT) {
val parentBottom = height - paddingBottom
val offset = parentBottom - bottom
super.layoutDecoratedWithMargins(child, left, top + offset, right, bottom + offset)
super.layoutDecoratedWithMargins(child, left, top, right, bottom + offset)
} else {
super.layoutDecoratedWithMargins(child, left, top, right, bottom)
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.ui.list
import android.app.Notification.Action
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
@@ -80,8 +81,7 @@ class ListSelectionController(
}
fun onItemLongClick(id: Long): Boolean {
startActionMode()
return actionMode?.also {
return startActionMode()?.also {
decoration.setItemIsChecked(id, true)
notifySelectionChanged()
} != null
@@ -105,9 +105,9 @@ class ListSelectionController(
actionMode = null
}
private fun startActionMode() {
if (actionMode == null) {
actionMode = appCompatDelegate.startSupportActionMode(this)
private fun startActionMode(): ActionMode? {
return actionMode ?: appCompatDelegate.startSupportActionMode(this).also {
actionMode = it
}
}

View File

@@ -2,7 +2,10 @@ package org.koitharu.kotatsu.core.ui.sheet
import android.app.Dialog
import android.view.View
import android.view.ViewParent
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.ancestors
import androidx.fragment.app.DialogFragment
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
import com.google.android.material.bottomsheet.BottomSheetDialog
@@ -109,7 +112,16 @@ sealed class AdaptiveSheetBehavior {
const val STATE_DRAGGING = SideSheetBehavior.STATE_DRAGGING
const val STATE_HIDDEN = SideSheetBehavior.STATE_HIDDEN
fun from(dialog: Dialog?): AdaptiveSheetBehavior? = when (dialog) {
fun from(fragment: DialogFragment): AdaptiveSheetBehavior? {
from(fragment.dialog)?.let { return it }
val rootView = fragment.view ?: return null
for (parent in rootView.ancestors) {
from(parent)?.let { return it }
}
return null
}
private fun from(dialog: Dialog?): AdaptiveSheetBehavior? = when (dialog) {
is BottomSheetDialog -> Bottom(dialog.behavior)
is SideSheetDialog -> Side(dialog.behavior)
else -> null
@@ -121,5 +133,10 @@ sealed class AdaptiveSheetBehavior {
is SideSheetBehavior<*> -> Side(behavior)
else -> null
}
private fun from(parent: ViewParent): AdaptiveSheetBehavior? {
val lp = ((parent as? View)?.layoutParams as? CoordinatorLayout.LayoutParams) ?: return null
return from(lp)
}
}
}

View File

@@ -9,8 +9,10 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import androidx.activity.ComponentDialog
import androidx.activity.OnBackPressedDispatcher
import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDialog
import androidx.appcompat.app.AppCompatDialogFragment
import androidx.appcompat.view.ActionMode
@@ -20,11 +22,16 @@ import androidx.core.graphics.ColorUtils
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.viewbinding.ViewBinding
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.sidesheet.SideSheetDialog
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import com.google.android.material.R as materialR
@@ -43,16 +50,16 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
get() = requireViewBinding()
protected val behavior: AdaptiveSheetBehavior?
get() = AdaptiveSheetBehavior.from(dialog)
get() = AdaptiveSheetBehavior.from(this)
@JvmField
val actionModeDelegate = ActionModeDelegate()
var actionModeDelegate: ActionModeDelegate? = null
private set
val isExpanded: Boolean
get() = behavior?.state == AdaptiveSheetBehavior.STATE_EXPANDED
val onBackPressedDispatcher: OnBackPressedDispatcher
get() = requireComponentDialog().onBackPressedDispatcher
get() = (dialog as? ComponentDialog)?.onBackPressedDispatcher ?: requireActivity().onBackPressedDispatcher
var isLocked = false
private set
@@ -71,11 +78,15 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
final override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = requireViewBinding()
if (actionModeDelegate == null) {
actionModeDelegate = (activity as? BaseActivity<*>)?.actionModeDelegate
}
onViewBindingCreated(binding, savedInstanceState)
}
override fun onDestroyView() {
viewBinding = null
actionModeDelegate = null
super.onDestroyView()
}
@@ -86,13 +97,15 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
} else {
BottomSheetDialogImpl(context, theme)
}
dialog.onBackPressedDispatcher.addCallback(actionModeDelegate)
actionModeDelegate = ActionModeDelegate().also {
dialog.onBackPressedDispatcher.addCallback(it)
}
return dialog
}
@CallSuper
protected open fun dispatchSupportActionModeStarted(mode: ActionMode) {
actionModeDelegate.onSupportActionModeStarted(mode)
actionModeDelegate?.onSupportActionModeStarted(mode)
val ctx = requireContext()
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
ColorUtils.compositeColors(
@@ -118,18 +131,21 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
@CallSuper
protected open fun dispatchSupportActionModeFinished(mode: ActionMode) {
actionModeDelegate.onSupportActionModeFinished(mode)
actionModeDelegate?.onSupportActionModeFinished(mode)
dialog?.window?.statusBarColor = defaultStatusBarColor
}
fun addSheetCallback(callback: AdaptiveSheetCallback) {
val b = behavior ?: return
fun addSheetCallback(callback: AdaptiveSheetCallback, lifecycleOwner: LifecycleOwner): Boolean {
val b = behavior ?: return false
b.addCallback(callback)
val rootView = dialog?.findViewById<View>(materialR.id.design_bottom_sheet)
?: dialog?.findViewById(materialR.id.coordinator)
?: view
if (rootView != null) {
callback.onStateChanged(rootView, b.state)
}
lifecycleOwner.lifecycle.addObserver(CallbackRemoveObserver(b, callback))
return true
}
protected abstract fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): B
@@ -137,8 +153,9 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit
fun startSupportActionMode(callback: ActionMode.Callback): ActionMode? {
val appCompatDialog = dialog as? AppCompatDialog ?: return null
return appCompatDialog.delegate.startSupportActionMode(callback)
val delegate =
(dialog as? AppCompatDialog)?.delegate ?: (activity as? AppCompatActivity)?.delegate ?: return null
return delegate.startSupportActionMode(callback)
}
protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) {
@@ -283,4 +300,16 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
}
}
}
private class CallbackRemoveObserver(
private val behavior: AdaptiveSheetBehavior,
private val callback: AdaptiveSheetCallback,
) : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) {
super.onDestroy(owner)
owner.lifecycle.removeObserver(this)
behavior.removeCallback(callback)
}
}
}

View File

@@ -0,0 +1,31 @@
package org.koitharu.kotatsu.core.ui.util
import android.view.View
import androidx.activity.OnBackPressedCallback
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED
import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HALF_EXPANDED
import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged
class BottomSheetClollapseCallback(
private val behavior: BottomSheetBehavior<*>,
) : OnBackPressedCallback(behavior.state == STATE_EXPANDED) {
init {
behavior.addBottomSheetCallback(
object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(view: View, state: Int) {
isEnabled = state == STATE_EXPANDED || state == STATE_HALF_EXPANDED
}
override fun onSlide(p0: View, p1: Float) = Unit
},
)
}
override fun handleOnBackPressed() {
behavior.state = STATE_COLLAPSED
}
}

View File

@@ -0,0 +1,144 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import androidx.core.content.withStyledAttributes
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
import androidx.viewpager2.widget.ViewPager2
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.measureDimension
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.parsers.util.toIntUp
import com.google.android.material.R as materialR
class DotsIndicator @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.dotIndicatorStyle,
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private var indicatorSize = context.resources.resolveDp(12f)
private var dotSpacing = 0f
private var smallDotScale = 0.33f
private var smallDotAlpha = 0.6f
private var positionOffset: Float = 0f
private var position: Int = 0
private val inset = context.resources.resolveDp(1f)
var max: Int = 6
set(value) {
if (field != value) {
field = value
requestLayout()
invalidate()
}
}
var progress: Int
get() = position
set(value) {
if (position != value) {
position = value
invalidate()
}
}
init {
paint.style = Paint.Style.FILL
context.withStyledAttributes(attrs, R.styleable.DotsIndicator, defStyleAttr) {
paint.color = getColor(
R.styleable.DotsIndicator_dotColor,
context.getThemeColor(materialR.attr.colorOnBackground, Color.DKGRAY),
)
indicatorSize = getDimension(R.styleable.DotsIndicator_dotSize, indicatorSize)
dotSpacing = getDimension(R.styleable.DotsIndicator_dotSpacing, dotSpacing)
smallDotScale = getFloat(R.styleable.DotsIndicator_dotScale, smallDotScale).coerceIn(0f, 1f)
smallDotAlpha = getFloat(R.styleable.DotsIndicator_dotAlpha, smallDotAlpha).coerceIn(0f, 1f)
max = getInt(R.styleable.DotsIndicator_android_max, max)
position = getInt(R.styleable.DotsIndicator_android_progress, position)
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val dotSize = getDotSize()
val y = paddingTop + (height - paddingTop - paddingBottom) / 2f
var x = paddingLeft + dotSize / 2f
val radius = dotSize / 2f - inset
val spacing = (width - paddingLeft - paddingRight) / max.toFloat() - dotSize
x += spacing / 2f
for (i in 0 until max) {
val scale = when (i) {
position -> (1f - smallDotScale) * (1f - positionOffset) + smallDotScale
position + 1 -> (1f - smallDotScale) * positionOffset + smallDotScale
else -> smallDotScale
}
paint.alpha = (255 * when (i) {
position -> (1f - smallDotAlpha) * (1f - positionOffset) + smallDotAlpha
position + 1 -> (1f - smallDotAlpha) * positionOffset + smallDotAlpha
else -> smallDotAlpha
}).toInt()
canvas.drawCircle(x, y, radius * scale, paint)
x += spacing + dotSize
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val dotSize = getDotSize()
val desiredHeight = (dotSize + paddingTop + paddingBottom).toIntUp()
val desiredWidth = ((dotSize + dotSpacing) * max).toIntUp() + paddingLeft + paddingRight
setMeasuredDimension(
measureDimension(desiredWidth, widthMeasureSpec),
measureDimension(desiredHeight, heightMeasureSpec),
)
}
fun bindToViewPager(pager: ViewPager2) {
pager.registerOnPageChangeCallback(ViewPagerCallback())
pager.adapter?.let {
it.registerAdapterDataObserver(AdapterObserver(it))
}
}
private fun getDotSize() = if (indicatorSize <= 0) {
(height - paddingTop - paddingBottom).toFloat()
} else {
indicatorSize
}
private inner class ViewPagerCallback : ViewPager2.OnPageChangeCallback() {
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
super.onPageScrolled(position, positionOffset, positionOffsetPixels)
this@DotsIndicator.position = position
this@DotsIndicator.positionOffset = positionOffset
invalidate()
}
}
private inner class AdapterObserver(
private val adapter: RecyclerView.Adapter<*>,
) : AdapterDataObserver() {
override fun onChanged() {
super.onChanged()
max = adapter.itemCount
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
super.onItemRangeInserted(positionStart, itemCount)
max = adapter.itemCount
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
super.onItemRangeRemoved(positionStart, itemCount)
max = adapter.itemCount
}
}
}

View File

@@ -25,6 +25,15 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
private var dyRatio = 1F
var isPinned: Boolean = false
set(value) {
field = value
if (value) {
offsetAnimator?.cancel()
offsetAnimator = null
}
}
override fun layoutDependsOn(parent: CoordinatorLayout, child: BottomNavigationView, dependency: View): Boolean {
return dependency is AppBarLayout
}
@@ -51,7 +60,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
axes: Int,
type: Int,
): Boolean {
if (axes != ViewCompat.SCROLL_AXIS_VERTICAL) {
if (isPinned || axes != ViewCompat.SCROLL_AXIS_VERTICAL) {
return false
}
lastStartedType = type
@@ -69,7 +78,9 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
type: Int,
) {
super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type)
child.translationY = (child.translationY + (dy * dyRatio)).coerceIn(0F, child.height.toFloat())
if (!isPinned) {
child.translationY = (child.translationY + (dy * dyRatio)).coerceIn(0F, child.height.toFloat())
}
}
override fun onStopNestedScroll(
@@ -78,7 +89,7 @@ class HideBottomNavigationOnScrollBehavior @JvmOverloads constructor(
target: View,
type: Int,
) {
if (lastStartedType == ViewCompat.TYPE_TOUCH || type == ViewCompat.TYPE_NON_TOUCH) {
if (!isPinned && (lastStartedType == ViewCompat.TYPE_TOUCH || type == ViewCompat.TYPE_NON_TOUCH)) {
animateBottomNavigationVisibility(child, child.translationY < child.height / 2)
}
}

View File

@@ -2,7 +2,9 @@ package org.koitharu.kotatsu.core.ui.widgets
import android.animation.ValueAnimator
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Outline
import android.graphics.Paint
import android.util.AttributeSet
@@ -18,7 +20,7 @@ import androidx.core.view.children
import androidx.core.widget.TextViewCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.core.util.ext.textAndVisible
@@ -35,9 +37,8 @@ class ProgressButton @JvmOverloads constructor(
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private var progress = 0f
private var colorBase = context.getThemeColor(materialR.attr.colorPrimaryContainer)
private var colorProgress = context.getThemeColor(materialR.attr.colorPrimary)
private var colorText = context.getThemeColor(materialR.attr.colorOnPrimaryContainer)
private var colorBase: ColorStateList = ColorStateList.valueOf(Color.TRANSPARENT)
private var colorProgress: ColorStateList = ColorStateList.valueOf(Color.TRANSPARENT)
private var progressAnimator: ValueAnimator? = null
var title: CharSequence?
@@ -69,11 +70,14 @@ class ProgressButton @JvmOverloads constructor(
)
textViewTitle.text = getText(R.styleable.ProgressButton_title)
textViewSubtitle.text = getText(R.styleable.ProgressButton_subtitle)
colorBase = getColor(R.styleable.ProgressButton_baseColor, colorBase)
colorProgress = getColor(R.styleable.ProgressButton_progressColor, colorProgress)
colorText = getColor(R.styleable.ProgressButton_textColor, colorText)
textViewTitle.setTextColor(colorText)
textViewSubtitle.setTextColor(colorText)
colorBase = getColorStateList(R.styleable.ProgressButton_baseColor)
?: context.getThemeColorStateList(materialR.attr.colorPrimaryContainer) ?: colorBase
colorProgress = getColorStateList(R.styleable.ProgressButton_progressColor)
?: context.getThemeColorStateList(materialR.attr.colorPrimary) ?: colorProgress
getColorStateList(R.styleable.ProgressButton_android_textColor)?.let { colorText ->
textViewTitle.setTextColor(colorText)
textViewSubtitle.setTextColor(colorText)
}
progress = getInt(R.styleable.ProgressButton_android_progress, 0).toFloat() /
getInt(R.styleable.ProgressButton_android_max, 100).toFloat()
}
@@ -87,15 +91,15 @@ class ProgressButton @JvmOverloads constructor(
)
paint.style = Paint.Style.FILL
paint.color = colorProgress
paint.alpha = 84 // 255 * 0.33F
applyGravity()
setWillNotDraw(false)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawColor(colorBase)
canvas.drawColor(colorBase.getColorForState(drawableState, colorBase.defaultColor))
paint.color = colorProgress.getColorForState(drawableState, colorProgress.defaultColor)
paint.alpha = 84 // 255 * 0.33F
canvas.drawRect(0f, 0f, width * progress, height.toFloat(), paint)
}

View File

@@ -38,6 +38,15 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
private var currentState = STATE_UP
private var behavior = HideBottomNavigationOnScrollBehavior()
var isPinned: Boolean
get() = behavior.isPinned
set(value) {
behavior.isPinned = value
if (value) {
translationX = 0f
}
}
override fun getBehavior(): CoordinatorLayout.Behavior<*> {
return behavior
}

View File

@@ -12,6 +12,7 @@ import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes
import androidx.core.view.isVisible
import androidx.core.view.setPadding
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
@@ -103,16 +104,22 @@ class TipView @JvmOverloads constructor(
fun setPrimaryButtonText(@StringRes resId: Int) {
binding.buttonPrimary.setTextAndVisible(resId)
updateButtonsLayout()
}
fun setSecondaryButtonText(@StringRes resId: Int) {
binding.buttonSecondary.setTextAndVisible(resId)
updateButtonsLayout()
}
fun setIcon(@DrawableRes resId: Int) {
icon = ContextCompat.getDrawable(context, resId)
}
private fun updateButtonsLayout() {
binding.layoutButtons.isVisible = binding.buttonPrimary.isVisible || binding.buttonSecondary.isVisible
}
interface OnButtonClickListener {
fun onPrimaryButtonClick(tipView: TipView)

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.core.util
import kotlinx.coroutines.CoroutineExceptionHandler
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.report
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
class AcraCoroutineErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler),
CoroutineExceptionHandler {
override fun handleException(context: CoroutineContext, exception: Throwable) {
exception.printStackTraceDebug()
exception.report()
}
}

View File

@@ -1,58 +0,0 @@
package org.koitharu.kotatsu.core.util
import androidx.collection.ArrayMap
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.coroutineContext
@Deprecated("", replaceWith = ReplaceWith("CompositeMutex2"))
class CompositeMutex<T : Any> : Set<T> {
private val state = ArrayMap<T, MutableStateFlow<Boolean>>()
private val mutex = Mutex()
override val size: Int
get() = state.size
override fun contains(element: T): Boolean {
return state.containsKey(element)
}
override fun containsAll(elements: Collection<T>): Boolean {
return elements.all { x -> state.containsKey(x) }
}
override fun isEmpty(): Boolean {
return state.isEmpty()
}
override fun iterator(): Iterator<T> {
return state.keys.iterator()
}
suspend fun lock(element: T) {
while (coroutineContext.isActive) {
waitForRemoval(element)
mutex.withLock {
if (state[element] == null) {
state[element] = MutableStateFlow(false)
return
}
}
}
}
fun unlock(element: T) {
checkNotNull(state.remove(element)) {
"CompositeMutex is not locked for $element"
}.value = true
}
private suspend fun waitForRemoval(element: T) {
val flow = state[element] ?: return
flow.first { it }
}
}

View File

@@ -1,12 +0,0 @@
package org.koitharu.kotatsu.core.util
class CompositeRunnable(
private val children: List<Runnable>,
) : Runnable, Collection<Runnable> by children {
override fun run() {
for (child in children) {
child.run()
}
}
}

View File

@@ -9,8 +9,8 @@ class Event<T>(
suspend fun consume(collector: FlowCollector<T>) {
if (!isConsumed) {
collector.emit(data)
isConsumed = true
collector.emit(data)
}
}

View File

@@ -1,46 +0,0 @@
package org.koitharu.kotatsu.core.util
import android.app.Activity
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.DefaultActivityLifecycleCallbacks
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class IncognitoModeIndicator @Inject constructor(
private val settings: AppSettings,
) : DefaultActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
if (activity !is AppCompatActivity) {
return
}
settings.observeAsFlow(
key = AppSettings.KEY_INCOGNITO_MODE,
valueProducer = { isIncognitoModeEnabled },
).flowOn(Dispatchers.IO)
.flowWithLifecycle(activity.lifecycle)
.onEach { updateStatusBar(activity, it) }
.launchIn(activity.lifecycleScope)
}
private fun updateStatusBar(activity: AppCompatActivity, isIncognitoModeEnabled: Boolean) {
activity.window.statusBarColor = if (isIncognitoModeEnabled) {
ContextCompat.getColor(activity, R.color.status_bar_incognito)
} else {
activity.getThemeColor(android.R.attr.statusBarColor)
}
}
}

View File

@@ -3,7 +3,7 @@ package org.koitharu.kotatsu.core.util
import androidx.collection.ArrayMap
import kotlinx.coroutines.sync.Mutex
class CompositeMutex2<T : Any> : Set<T> {
class MultiMutex<T : Any> : Set<T> {
private val delegates = ArrayMap<T, Mutex>()

View File

@@ -65,6 +65,7 @@ import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.File
import kotlin.math.roundToLong
import com.google.android.material.R as materialR
val Context.activityManager: ActivityManager?
get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager
@@ -141,7 +142,7 @@ fun Window.setNavigationBarTransparentCompat(context: Context, elevation: Float,
} else {
// Set navbar scrim 70% of navigationBarColor
ElevationOverlayProvider(context).compositeOverlayIfNeeded(
context.getThemeColor(com.google.android.material.R.attr.colorSurfaceContainer, alphaFactor),
context.getThemeColor(materialR.attr.colorSurfaceContainer, alphaFactor),
elevation,
)
}

View File

@@ -1,7 +1,9 @@
package org.koitharu.kotatsu.core.util.ext
import android.content.Context
import android.graphics.drawable.ColorDrawable
import android.widget.ImageView
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
@@ -12,9 +14,11 @@ import coil.request.SuccessResult
import coil.util.CoilUtils
import com.google.android.material.progressindicator.BaseProgressIndicator
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.image.AnimatedPlaceholderDrawable
import org.koitharu.kotatsu.core.ui.image.RegionBitmapDecoder
import org.koitharu.kotatsu.core.util.progress.ImageRequestIndicatorListener
import org.koitharu.kotatsu.parsers.model.MangaSource
import com.google.android.material.R as materialR
fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): ImageRequest.Builder? {
val current = CoilUtils.result(this)
@@ -85,6 +89,17 @@ fun ImageRequest.Builder.source(source: MangaSource?): ImageRequest.Builder {
return tag(MangaSource::class.java, source)
}
fun ImageRequest.Builder.defaultPlaceholders(context: Context): ImageRequest.Builder {
val errorColor = ColorUtils.blendARGB(
context.getThemeColor(materialR.attr.colorErrorContainer),
context.getThemeColor(materialR.attr.colorBackgroundFloating),
0.25f,
)
return placeholder(AnimatedPlaceholderDrawable(context))
.fallback(ColorDrawable(context.getThemeColor(materialR.attr.colorSurfaceContainer)))
.error(ColorDrawable(errorColor))
}
fun ImageRequest.Builder.addListener(listener: ImageRequest.Listener): ImageRequest.Builder {
val existing = build().listener
return listener(

View File

@@ -68,3 +68,5 @@ fun <T> Iterable<T>.sortedWithSafe(comparator: Comparator<in T>): List<T> = try
toList()
}
}
fun Collection<*>?.sizeOrZero() = if (this == null) 0 else size

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.core.util.ext
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleCoroutineScope
import androidx.lifecycle.LifecycleDestroyedException
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleObserver
@@ -10,17 +9,20 @@ import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.lifecycle.RetainedLifecycle
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.plus
import kotlinx.coroutines.suspendCancellableCoroutine
import org.koitharu.kotatsu.core.util.AcraCoroutineErrorHandler
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import kotlin.coroutines.EmptyCoroutineContext
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
val processLifecycleScope: LifecycleCoroutineScope
inline get() = ProcessLifecycleOwner.get().lifecycleScope
val processLifecycleScope: CoroutineScope
get() = ProcessLifecycleOwner.get().lifecycleScope + AcraCoroutineErrorHandler()
val RetainedLifecycle.lifecycleScope: RetainedLifecycleCoroutineScope
inline get() = RetainedLifecycleCoroutineScope(this)

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.util.ext
import android.util.Log
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
@@ -28,7 +29,7 @@ fun <T> Flow<T>.observe(owner: LifecycleOwner, minState: Lifecycle.State, collec
fun <T> Flow<Event<T>?>.observeEvent(owner: LifecycleOwner, collector: FlowCollector<T>) {
owner.lifecycleScope.launch {
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
owner.repeatOnLifecycle(Lifecycle.State.STARTED) {
collect {
it?.consume(collector)
}

View File

@@ -1,17 +1,12 @@
package org.koitharu.kotatsu.core.util.ext
import android.os.Bundle
import androidx.annotation.MainThread
import androidx.core.view.MenuProvider
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.lifecycle.coroutineScope
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
inline fun <T : Fragment> T.withArgs(size: Int, block: Bundle.() -> Unit): T {
val b = Bundle(size)
@@ -33,26 +28,6 @@ fun Fragment.addMenuProvider(provider: MenuProvider) {
requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.RESUMED)
}
@MainThread
suspend fun Fragment.awaitViewLifecycle(): LifecycleOwner {
val liveData = viewLifecycleOwnerLiveData
liveData.value?.let { return it }
return suspendCancellableCoroutine { cont ->
val observer = object : Observer<LifecycleOwner?> {
override fun onChanged(value: LifecycleOwner?) {
if (value != null) {
liveData.removeObserver(this)
cont.resume(value)
}
}
}
liveData.observeForever(observer)
cont.invokeOnCancellation {
liveData.removeObserver(observer)
}
}
}
fun DialogFragment.showDistinct(fm: FragmentManager, tag: String) {
val existing = fm.findFragmentByTag(tag) as? DialogFragment?
if (existing != null && existing.isVisible && existing.arguments == this.arguments) {

View File

@@ -1,7 +1,5 @@
package org.koitharu.kotatsu.core.util.ext
import org.koitharu.kotatsu.core.util.CompositeRunnable
@Suppress("UNCHECKED_CAST")
fun <T> Class<T>.castOrNull(obj: Any?): T? {
if (obj == null || !isInstance(obj)) {
@@ -9,15 +7,3 @@ fun <T> Class<T>.castOrNull(obj: Any?): T? {
}
return obj as T
}
/* CompositeRunnable */
operator fun Runnable.plus(other: Runnable): Runnable {
val list = ArrayList<Runnable>(this.size + other.size)
if (this is CompositeRunnable) list.addAll(this) else list.add(this)
if (other is CompositeRunnable) list.addAll(other) else list.add(other)
return CompositeRunnable(list)
}
private val Runnable.size: Int
get() = if (this is CompositeRunnable) size else 1

View File

@@ -1,11 +0,0 @@
package org.koitharu.kotatsu.core.util.ext
import android.graphics.Canvas
import android.text.StaticLayout
import androidx.core.graphics.withTranslation
fun StaticLayout.draw(canvas: Canvas, x: Float, y: Float) {
canvas.withTranslation(x, y) {
draw(this)
}
}

View File

@@ -12,6 +12,7 @@ import org.jsoup.HttpStatusException
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
import org.koitharu.kotatsu.core.exceptions.CaughtException
import org.koitharu.kotatsu.core.exceptions.CloudFlareBlockedException
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
@@ -38,6 +39,7 @@ private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
is AuthRequiredException -> resources.getString(R.string.auth_required)
is CloudFlareProtectedException -> resources.getString(R.string.captcha_required)
is CloudFlareBlockedException -> resources.getString(R.string.blocked_by_server_message)
is ActivityNotFoundException,
is UnsupportedOperationException,
-> resources.getString(R.string.operation_not_supported)
@@ -79,6 +81,8 @@ fun Throwable.getDisplayIcon() = when (this) {
is SocketTimeoutException,
-> R.drawable.ic_plug_large
is CloudFlareBlockedException -> R.drawable.ic_denied_large
else -> R.drawable.ic_error_large
}

View File

@@ -1,12 +1,10 @@
package org.koitharu.kotatsu.core.util.ext
import android.annotation.SuppressLint
import androidx.annotation.MainThread
import androidx.fragment.app.Fragment
import androidx.fragment.app.createViewModelLazy
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.viewmodel.CreationExtras
@MainThread
@@ -19,7 +17,3 @@ inline fun <reified VM : ViewModel> Fragment.parentFragmentViewModels(
extrasProducer = { extrasProducer?.invoke() ?: requireParentFragment().defaultViewModelCreationExtras },
factoryProducer = factoryProducer ?: { requireParentFragment().defaultViewModelProviderFactory },
)
val ViewModelStore.values: Collection<ViewModel>
@SuppressLint("RestrictedApi")
get() = this.keys().mapNotNull { get(it) }

View File

@@ -83,7 +83,6 @@ suspend fun WorkManager.getWorkSpec(id: UUID): WorkSpec? = suspendCoroutine { co
}
}
@SuppressLint("RestrictedApi")
suspend fun WorkManager.getWorkInputData(id: UUID): Data? = getWorkSpec(id)?.input

View File

@@ -10,6 +10,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import okio.IOException
import org.koitharu.kotatsu.core.model.isLocal
@@ -17,6 +18,7 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.util.ext.peek
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.explore.domain.RecoverMangaUseCase
@@ -25,7 +27,9 @@ import org.koitharu.kotatsu.parsers.exception.NotFoundException
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.recoverNotNull
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.tracker.domain.Tracker
import javax.inject.Inject
import javax.inject.Provider
class DetailsLoadUseCase @Inject constructor(
private val mangaDataRepository: MangaDataRepository,
@@ -33,6 +37,7 @@ class DetailsLoadUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val recoverUseCase: RecoverMangaUseCase,
private val imageGetter: Html.ImageGetter,
private val trackerProvider: Provider<Tracker>,
) {
operator fun invoke(intent: MangaIntent): Flow<MangaDetails> = channelFlow {
@@ -49,6 +54,7 @@ class DetailsLoadUseCase @Inject constructor(
send(MangaDetails(manga, null, null, false))
try {
val details = getDetails(manga)
launch { updateTracker(manga) }
send(MangaDetails(details, local?.peek(), details.description?.parseAsHtml(withImages = false), false))
send(MangaDetails(details, local?.await(), details.description?.parseAsHtml(withImages = true), true))
} catch (e: IOException) {
@@ -90,4 +96,10 @@ class DetailsLoadUseCase @Inject constructor(
}
return spannable
}
private suspend fun updateTracker(details: Manga) = runCatchingCancellable {
trackerProvider.get().syncWithDetails(details)
}.onFailure { e ->
e.printStackTraceDebug()
}
}

View File

@@ -1,92 +0,0 @@
package org.koitharu.kotatsu.details.ui
import android.transition.TransitionManager
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.graphics.Insets
import androidx.core.view.setMargins
import androidx.core.view.updateLayoutParams
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeDimensionPixelSize
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.databinding.ItemTipBinding
import com.google.android.material.R as materialR
class ButtonTip(
private val root: ViewGroup,
private val insetsDelegate: WindowInsetsDelegate,
private val viewModel: DetailsViewModel,
) : View.OnClickListener, WindowInsetsDelegate.WindowInsetsListener {
private var selfBinding = ItemTipBinding.inflate(LayoutInflater.from(root.context), root, false)
private val actionBarSize = root.context.getThemeDimensionPixelSize(materialR.attr.actionBarSize)
init {
selfBinding.textView.setText(R.string.details_button_tip)
selfBinding.imageViewIcon.setImageResource(R.drawable.ic_tap)
selfBinding.root.id = R.id.layout_tip
selfBinding.buttonClose.setOnClickListener(this)
}
override fun onClick(v: View?) {
remove()
}
override fun onWindowInsetsChanged(insets: Insets) {
if (root is CoordinatorLayout) {
selfBinding.root.updateLayoutParams<CoordinatorLayout.LayoutParams> {
bottomMargin = topMargin + insets.bottom + insets.top + actionBarSize
}
}
}
fun addToRoot() {
val lp: ViewGroup.LayoutParams = when (root) {
is CoordinatorLayout -> CoordinatorLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply {
// anchorId = R.id.layout_bottom
// anchorGravity = Gravity.TOP
gravity = Gravity.BOTTOM
setMargins(root.resources.getDimensionPixelOffset(R.dimen.margin_normal))
bottomMargin += actionBarSize
}
is ConstraintLayout -> ConstraintLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply {
width = root.resources.getDimensionPixelSize(R.dimen.m3_side_sheet_width)
setMargins(root.resources.getDimensionPixelOffset(R.dimen.margin_normal))
}
else -> ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
}
root.addView(selfBinding.root, lp)
if (root is ConstraintLayout) {
val cs = ConstraintSet()
cs.clone(root)
cs.connect(R.id.layout_tip, ConstraintSet.TOP, R.id.appbar, ConstraintSet.BOTTOM)
cs.connect(R.id.layout_tip, ConstraintSet.START, R.id.card_chapters, ConstraintSet.START)
cs.connect(R.id.layout_tip, ConstraintSet.END, R.id.card_chapters, ConstraintSet.END)
cs.applyTo(root)
}
insetsDelegate.addInsetsListener(this)
}
fun remove() {
if (root.context.isAnimationsEnabled) {
TransitionManager.beginDelayedTransition(root)
}
insetsDelegate.removeInsetsListener(this)
root.removeView(selfBinding.root)
viewModel.onButtonTipClosed()
}
}

View File

@@ -1,97 +0,0 @@
package org.koitharu.kotatsu.details.ui
import android.view.InputDevice
import android.view.MotionEvent
import android.view.View
import android.view.View.OnLayoutChangeListener
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.view.ActionMode
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.tabs.TabLayout
import org.koitharu.kotatsu.core.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged
import org.koitharu.kotatsu.core.util.ext.setTabsEnabled
class ChaptersBottomSheetMediator(
private val behavior: BottomSheetBehavior<*>,
private val pager: ViewPager2,
private val tabLayout: TabLayout,
) : OnBackPressedCallback(false),
ActionModeListener,
OnLayoutChangeListener, View.OnGenericMotionListener {
private var lockCounter = 0
init {
behavior.doOnExpansionsChanged { isExpanded ->
isEnabled = isExpanded
if (!isExpanded) {
unlock()
}
}
}
override fun handleOnBackPressed() {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
override fun onActionModeStarted(mode: ActionMode) {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
lock()
}
override fun onActionModeFinished(mode: ActionMode) {
unlock()
}
override fun onLayoutChange(
v: View?,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int,
) {
val height = bottom - top
if (height != behavior.peekHeight) {
behavior.peekHeight = height
}
}
override fun onGenericMotion(v: View?, event: MotionEvent): Boolean {
if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
if (event.actionMasked == MotionEvent.ACTION_SCROLL) {
if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0f) {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
} else {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
return true
}
}
return false
}
fun lock() {
lockCounter++
updateLock()
}
fun unlock() {
lockCounter--
if (lockCounter < 0) {
lockCounter = 0
}
updateLock()
}
private fun updateLock() {
behavior.isDraggable = lockCounter <= 0
pager.isUserInputEnabled = lockCounter <= 0
tabLayout.setTabsEnabled(lockCounter <= 0)
}
}

View File

@@ -72,7 +72,8 @@ fun MangaDetails.mapChapters(
fun List<ChapterListItem>.withVolumeHeaders(context: Context): List<ListModel> {
var prevVolume = 0
val result = ArrayList<ListModel>((size * 1.4).toInt())
for (item in this) {
var groupPos: Byte = 0
for ((index, item) in this.withIndex()) {
val chapter = item.chapter
if (chapter.volume != prevVolume) {
val text = if (chapter.volume == 0) {
@@ -82,8 +83,19 @@ fun List<ChapterListItem>.withVolumeHeaders(context: Context): List<ListModel> {
}
result.add(ListHeader(text))
prevVolume = chapter.volume
groupPos = ChapterListItem.GROUP_START
} else if (groupPos == ChapterListItem.GROUP_START) {
groupPos = ChapterListItem.GROUP_MIDDLE
}
if (groupPos != 0.toByte()) {
val next = this.getOrNull(index + 1)
if (next == null || next.chapter.volume != prevVolume) {
groupPos = ChapterListItem.GROUP_END
}
result.add(item.copy(groupPosition = groupPos))
} else {
result.add(item)
}
result.add(item)
}
return result
}

View File

@@ -1,73 +0,0 @@
package org.koitharu.kotatsu.details.ui
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.widget.SearchView
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
import java.lang.ref.WeakReference
class ChaptersMenuProvider(
private val viewModel: DetailsViewModel,
private val bottomSheetMediator: ChaptersBottomSheetMediator?,
) : OnBackPressedCallback(false), MenuProvider, SearchView.OnQueryTextListener, MenuItem.OnActionExpandListener {
private var searchItemRef: WeakReference<MenuItem>? = null
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_chapters, menu)
val searchMenuItem = menu.findItem(R.id.action_search)
searchMenuItem.setOnActionExpandListener(this)
val searchView = searchMenuItem.actionView as SearchView
searchView.setOnQueryTextListener(this)
searchView.setIconifiedByDefault(false)
searchView.queryHint = searchMenuItem.title
searchItemRef = WeakReference(searchMenuItem)
}
override fun onPrepareMenu(menu: Menu) {
menu.findItem(R.id.action_search)?.isVisible = viewModel.isChaptersEmpty.value == false
menu.findItem(R.id.action_reversed)?.isChecked = viewModel.isChaptersReversed.value == true
menu.findItem(R.id.action_grid_view)?.isChecked = viewModel.isChaptersInGridView.value == true
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_reversed -> {
viewModel.setChaptersReversed(!menuItem.isChecked)
true
}
R.id.action_grid_view-> {
viewModel.setChaptersInGridView(!menuItem.isChecked)
true
}
else -> false
}
override fun handleOnBackPressed() {
searchItemRef?.get()?.collapseActionView()
}
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
bottomSheetMediator?.lock()
isEnabled = true
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
isEnabled = false
(item.actionView as? SearchView)?.setQuery("", false)
viewModel.performChapterSearch(null)
bottomSheetMediator?.unlock()
return true
}
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.performChapterSearch(newText)
return true
}
}

View File

@@ -8,66 +8,101 @@ import android.text.style.DynamicDrawableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.ImageSpan
import android.text.style.RelativeSizeSpan
import android.transition.AutoTransition
import android.transition.Slide
import android.transition.TransitionManager
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.ViewTreeObserver
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.view.MenuHost
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.preference.PreferenceManager
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import coil.ImageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
import coil.transform.CircleCropTransformation
import coil.util.CoilUtils
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.chip.Chip
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.iconResId
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.BottomSheetClollapseCallback
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ViewBadge
import org.koitharu.kotatsu.core.util.ext.crossfade
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.measureHeight
import org.koitharu.kotatsu.core.util.ext.menuView
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.isTextTruncated
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.data.ReadingTime
import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.pager.DetailsPagerAdapter
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet
import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
import java.lang.ref.WeakReference
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -75,25 +110,22 @@ import com.google.android.material.R as materialR
class DetailsActivity :
BaseActivity<ActivityDetailsBinding>(),
View.OnClickListener,
NoModalBottomSheetOwner,
View.OnLongClickListener,
PopupMenu.OnMenuItemClickListener {
View.OnLongClickListener, PopupMenu.OnMenuItemClickListener, View.OnLayoutChangeListener,
ViewTreeObserver.OnDrawListener, ChipsView.OnChipClickListener, OnListItemClickListener<Bookmark> {
@Inject
lateinit var appShortcutManager: AppShortcutManager
lateinit var shortcutManager: AppShortcutManager
@Inject
lateinit var settings: AppSettings
lateinit var coil: ImageLoader
private var buttonTip: WeakReference<ButtonTip>? = null
@Inject
lateinit var tagHighlighter: ListExtraProvider
private val viewModel: DetailsViewModel by viewModels()
val secondaryMenuHost: MenuHost
get() = viewBinding.toolbarChapters ?: this
var bottomSheetMediator: ChaptersBottomSheetMediator? = null
private set
private lateinit var chaptersBadge: ViewBadge
private lateinit var menuProvider: DetailsMenuProvider
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -105,78 +137,157 @@ class DetailsActivity :
viewBinding.buttonRead.setOnClickListener(this)
viewBinding.buttonRead.setOnLongClickListener(this)
viewBinding.buttonRead.setOnContextClickListenerCompat(this)
viewBinding.buttonDropdown.setOnClickListener(this)
if (viewBinding.layoutBottom != null) {
val behavior = BottomSheetBehavior.from(checkNotNull(viewBinding.layoutBottom))
val bsMediator = ChaptersBottomSheetMediator(behavior, viewBinding.pager, viewBinding.tabs)
actionModeDelegate.addListener(bsMediator)
checkNotNull(viewBinding.layoutBsHeader).addOnLayoutChangeListener(bsMediator)
onBackPressedDispatcher.addCallback(bsMediator)
bottomSheetMediator = bsMediator
behavior.doOnExpansionsChanged(::onChaptersSheetStateChanged)
viewBinding.toolbarChapters?.setNavigationOnClickListener {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
}
viewBinding.toolbarChapters?.setOnGenericMotionListener(bsMediator)
viewBinding.buttonDownload?.setOnClickListener(this)
viewBinding.infoLayout.chipBranch.setOnClickListener(this)
viewBinding.infoLayout.chipSize.setOnClickListener(this)
viewBinding.infoLayout.chipSource.setOnClickListener(this)
viewBinding.infoLayout.chipFavorite.setOnClickListener(this)
viewBinding.infoLayout.chipAuthor.setOnClickListener(this)
viewBinding.infoLayout.chipTime.setOnClickListener(this)
viewBinding.imageViewCover.setOnClickListener(this)
viewBinding.buttonDescriptionMore.setOnClickListener(this)
viewBinding.buttonScrobblingMore.setOnClickListener(this)
viewBinding.buttonRelatedMore.setOnClickListener(this)
viewBinding.infoLayout.chipSource.setOnClickListener(this)
viewBinding.infoLayout.chipSize.setOnClickListener(this)
viewBinding.textViewDescription.addOnLayoutChangeListener(this)
viewBinding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
viewBinding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
viewBinding.chipsTags.onChipClickListener = this
TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView)
viewBinding.containerBottomSheet?.let { BottomSheetBehavior.from(it) }?.let { behavior ->
onBackPressedDispatcher.addCallback(BottomSheetClollapseCallback(behavior))
}
initPager()
chaptersBadge = ViewBadge(viewBinding.buttonRead, this)
viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.details.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onError.observeEvent(this, DetailsErrorObserver(this, viewModel, exceptionResolver))
viewModel.onActionDone.observeEvent(
this,
ReversibleActionObserver(viewBinding.containerDetails, viewBinding.layoutBottom),
)
viewModel.onShowTip.observeEvent(this) { showTip() }
viewModel.historyInfo.observe(this, ::onHistoryChanged)
viewModel.selectedBranch.observe(this) {
viewBinding.toolbarChapters?.subtitle = it
viewBinding.textViewSubtitle?.textAndVisible = it
viewModel.onError
.filterNot { ChaptersPagesSheet.isShown(supportFragmentManager) }
.observeEvent(this, DetailsErrorObserver(this, viewModel, exceptionResolver))
viewModel.onActionDone
.filterNot { ChaptersPagesSheet.isShown(supportFragmentManager) }
.observeEvent(this, ReversibleActionObserver(viewBinding.scrollView, null))
combine(viewModel.historyInfo, viewModel.isLoading, ::Pair).observe(this) {
onHistoryChanged(it.first, it.second)
}
val chaptersMenuInvalidator = MenuInvalidator(viewBinding.toolbarChapters ?: this)
viewModel.isChaptersReversed.observe(this, chaptersMenuInvalidator)
viewModel.isChaptersEmpty.observe(this, chaptersMenuInvalidator)
viewModel.isLoading.observe(this, ::onLoadingStateChanged)
viewModel.scrobblingInfo.observe(this, ::onScrobblingInfoChanged)
viewModel.localSize.observe(this, ::onLocalSizeChanged)
viewModel.relatedManga.observe(this, ::onRelatedMangaChanged)
viewModel.readingTime.observe(this, ::onReadingTimeChanged)
viewModel.selectedBranch.observe(this) {
viewBinding.infoLayout.chipBranch.text = it.ifNullOrEmpty { getString(R.string.system_default) }
}
viewModel.favouriteCategories.observe(this, ::onFavoritesChanged)
val menuInvalidator = MenuInvalidator(this)
viewModel.favouriteCategories.observe(this, menuInvalidator)
viewModel.isStatsAvailable.observe(this, menuInvalidator)
viewModel.remoteManga.observe(this, menuInvalidator)
viewModel.branches.observe(this) {
viewBinding.buttonDropdown.isVisible = it.size > 1
viewBinding.infoLayout.chipBranch.isVisible = it.size > 1
}
viewModel.chapters.observe(this, PrefetchObserver(this))
viewModel.onDownloadStarted.observeEvent(
this,
DownloadStartedObserver(viewBinding.containerDetails),
)
viewModel.onDownloadStarted
.filterNot { ChaptersPagesSheet.isShown(supportFragmentManager) }
.observeEvent(this, DownloadStartedObserver(viewBinding.scrollView))
addMenuProvider(
DetailsMenuProvider(
activity = this,
viewModel = viewModel,
snackbarHost = viewBinding.pager,
appShortcutManager = appShortcutManager,
),
menuProvider = DetailsMenuProvider(
activity = this,
viewModel = viewModel,
snackbarHost = viewBinding.scrollView,
appShortcutManager = shortcutManager,
)
}
override fun getBottomSheetCollapsedHeight(): Int {
return viewBinding.layoutBsHeader?.measureHeight() ?: 0
addMenuProvider(menuProvider)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_read -> openReader(isIncognitoMode = false)
R.id.button_dropdown -> showBranchPopupMenu(v)
R.id.chip_branch -> showBranchPopupMenu(v)
R.id.button_download -> DownloadDialogHelper(v, viewModel).show(menuProvider)
R.id.chip_author -> {
val manga = viewModel.manga.value ?: return
startActivity(
SearchActivity.newIntent(
context = v.context,
source = manga.source,
query = manga.author ?: return,
),
)
}
R.id.chip_source -> {
val manga = viewModel.manga.value ?: return
startActivity(
MangaListActivity.newIntent(
context = v.context,
source = manga.source,
),
)
}
R.id.chip_size -> {
val manga = viewModel.manga.value ?: return
LocalInfoDialog.show(supportFragmentManager, manga)
}
R.id.chip_favorite -> {
val manga = viewModel.manga.value ?: return
FavoriteSheet.show(supportFragmentManager, manga)
}
R.id.chip_time -> {
if (viewModel.isStatsAvailable.value) {
val manga = viewModel.manga.value ?: return
MangaStatsSheet.show(supportFragmentManager, manga)
} else {
// TODO
}
}
R.id.imageView_cover -> {
val manga = viewModel.manga.value ?: return
startActivity(
ImageActivity.newIntent(
v.context,
manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl },
manga.source,
),
scaleUpActivityOptionsOf(v),
)
}
R.id.button_description_more -> {
val tv = viewBinding.textViewDescription
TransitionManager.beginDelayedTransition(tv.parentView)
if (tv.maxLines in 1 until Integer.MAX_VALUE) {
tv.maxLines = Integer.MAX_VALUE
} else {
tv.maxLines = resources.getInteger(R.integer.details_description_lines)
}
}
R.id.button_scrobbling_more -> {
val manga = viewModel.manga.value ?: return
ScrobblingSelectorSheet.show(supportFragmentManager, manga, null)
}
R.id.button_related_more -> {
val manga = viewModel.manga.value ?: return
startActivity(RelatedMangaActivity.newIntent(v.context, manga))
}
}
}
override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag ?: return
startActivity(MangaListActivity.newIntent(this, setOf(tag)))
}
override fun onLongClick(v: View): Boolean = when (v.id) {
R.id.button_read -> {
buttonTip?.get()?.remove()
buttonTip = null
val menu = PopupMenu(v.context, v)
menu.inflate(R.menu.popup_read)
menu.menu.findItem(R.id.action_forget)?.isVisible = viewModel.historyInfo.value.run {
@@ -203,46 +314,197 @@ class DetailsActivity :
true
}
R.id.action_pages_thumbs -> {
val history = viewModel.historyInfo.value.history
PagesThumbnailsSheet.show(
fm = supportFragmentManager,
manga = viewModel.manga.value ?: return false,
chapterId = history?.chapterId
?: viewModel.chapters.value.firstOrNull()?.chapter?.id
?: return false,
currentPage = history?.page ?: 0,
)
true
}
else -> false
}
}
private fun onChaptersSheetStateChanged(isExpanded: Boolean) {
val toolbar = viewBinding.toolbarChapters ?: return
if (isAnimationsEnabled) {
val transition = AutoTransition()
transition.duration = getAnimationDuration(R.integer.config_shorterAnimTime)
TransitionManager.beginDelayedTransition(toolbar, transition)
}
if (isExpanded) {
toolbar.setNavigationIconSafe(materialR.drawable.abc_ic_clear_material)
} else {
toolbar.navigationIcon = null
}
toolbar.menuView?.isVisible = isExpanded
viewBinding.buttonRead.isGone = isExpanded
override fun onItemClick(item: Bookmark, view: View) {
startActivity(
ReaderActivity.IntentBuilder(view.context).bookmark(item).incognito(true).build(),
)
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
private fun onMangaUpdated(manga: Manga) {
title = manga.title
val hasChapters = !manga.chapters.isNullOrEmpty()
viewBinding.buttonRead.isEnabled = hasChapters
invalidateOptionsMenu()
showBottomSheet(manga.chapters != null)
viewBinding.groupHeader?.isVisible = hasChapters
override fun onDraw() {
viewBinding.run {
buttonDescriptionMore.isVisible = textViewDescription.maxLines == Int.MAX_VALUE ||
textViewDescription.isTextTruncated
}
}
override fun onLayoutChange(
v: View?,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int
) {
with(viewBinding) {
buttonDescriptionMore.isVisible = textViewDescription.isTextTruncated
}
}
private fun onFavoritesChanged(categories: Set<FavouriteCategory>) {
val chip = viewBinding.infoLayout.chipFavorite
chip.setChipIconResource(if (categories.isEmpty()) R.drawable.ic_heart_outline else R.drawable.ic_heart)
chip.text = if (categories.isEmpty()) {
getString(R.string.add_to_favourites)
} else {
if (categories.size == 1) {
categories.first().title.ellipsize(FAV_LABEL_LIMIT)
}
buildString(FAV_LABEL_LIMIT + 6) {
for ((i, cat) in categories.withIndex()) {
if (i == 0) {
append(cat.title.ellipsize(FAV_LABEL_LIMIT - 4))
} else if (length + cat.title.length > FAV_LABEL_LIMIT) {
append(", ")
append(getString(R.string.list_ellipsize_pattern, categories.size - i))
break
} else {
append(", ")
append(cat.title)
}
}
}
}
}
private fun onReadingTimeChanged(time: ReadingTime?) {
val chip = viewBinding.infoLayout.chipTime
chip.textAndVisible = time?.formatShort(chip.resources)
}
private fun onDescriptionChanged(description: CharSequence?) {
val tv = viewBinding.textViewDescription
if (description.isNullOrBlank()) {
tv.setText(R.string.no_description)
} else {
tv.text = description
}
}
private fun onLocalSizeChanged(size: Long) {
val chip = viewBinding.infoLayout.chipSize
if (size == 0L) {
chip.isVisible = false
} else {
chip.text = FileSize.BYTES.format(chip.context, size)
chip.isVisible = true
}
}
private fun onRelatedMangaChanged(related: List<MangaItemModel>) {
if (related.isEmpty()) {
viewBinding.groupRelated.isVisible = false
return
}
val rv = viewBinding.recyclerViewRelated
@Suppress("UNCHECKED_CAST")
val adapter = (rv.adapter as? BaseListAdapter<ListModel>) ?: BaseListAdapter<ListModel>()
.addDelegate(
ListItemType.MANGA_GRID,
mangaGridItemAD(
coil, this,
StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)),
) { item, view ->
startActivity(newIntent(view.context, item))
},
).also { rv.adapter = it }
adapter.items = related
viewBinding.groupRelated.isVisible = true
}
private fun onLoadingStateChanged(isLoading: Boolean) {
val button = viewBinding.buttonDownload ?: return
if (isLoading) {
button.setImageDrawable(
CircularProgressDrawable(this).also {
it.setStyle(CircularProgressDrawable.LARGE)
it.setColorSchemeColors(getThemeColor(materialR.attr.colorControlNormal))
it.start()
},
)
} else {
button.setImageResource(R.drawable.ic_download)
}
}
private fun onScrobblingInfoChanged(scrobblings: List<ScrobblingInfo>) {
var adapter = viewBinding.recyclerViewScrobbling.adapter as? ScrollingInfoAdapter
viewBinding.groupScrobbling.isGone = scrobblings.isEmpty()
if (adapter != null) {
adapter.items = scrobblings
} else {
adapter = ScrollingInfoAdapter(this, coil, supportFragmentManager)
adapter.items = scrobblings
viewBinding.recyclerViewScrobbling.adapter = adapter
viewBinding.recyclerViewScrobbling.addItemDecoration(ScrobblingItemDecoration())
}
}
private fun onMangaUpdated(details: MangaDetails) {
with(viewBinding) {
val manga = details.toManga()
// Main
loadCover(manga)
textViewTitle.text = manga.title
textViewSubtitle.textAndVisible = manga.altTitle
infoLayout.chipAuthor.textAndVisible = manga.author
if (manga.hasRating) {
ratingBar.rating = manga.rating * ratingBar.numStars
ratingBar.isVisible = true
} else {
ratingBar.isVisible = false
}
manga.state?.let { state ->
textViewState.textAndVisible = resources.getString(state.titleResId)
imageViewState.setImageResource(state.iconResId)
imageViewState.isVisible = true
} ?: run {
textViewState.isVisible = false
imageViewState.isVisible = false
}
if (manga.source == MangaSource.LOCAL || manga.source == MangaSource.DUMMY) {
infoLayout.chipSource.isVisible = false
} else {
infoLayout.chipSource.text = manga.source.title
infoLayout.chipSource.isVisible = true
}
textViewNsfw.isVisible = manga.isNsfw
// Chips
bindTags(manga)
textViewDescription.text = details.description.ifNullOrEmpty { getString(R.string.no_description) }
viewBinding.infoLayout.chipSource.also { chip ->
ImageRequest.Builder(this@DetailsActivity)
.data(manga.source.faviconUri())
.lifecycle(this@DetailsActivity)
.crossfade(false)
.size(resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
.target(ChipIconTarget(chip))
.placeholder(R.drawable.ic_web)
.fallback(R.drawable.ic_web)
.error(R.drawable.ic_web)
.source(manga.source)
.transformations(CircleCropTransformation())
.allowRgb565(true)
.enqueueWith(coil)
}
title = manga.title
invalidateOptionsMenu()
}
}
private fun onMangaRemoved(manga: Manga) {
@@ -259,60 +521,37 @@ class DetailsActivity :
left = insets.left,
right = insets.right,
)
if (insets.bottom > 0) {
window.setNavigationBarTransparentCompat(
this,
viewBinding.layoutBottom?.elevation ?: 0f,
0.9f,
)
}
viewBinding.cardChapters?.updateLayoutParams<MarginLayoutParams> {
bottomMargin = insets.bottom + marginEnd
val baseOffset = leftMargin
bottomMargin = insets.bottom + baseOffset
topMargin = insets.bottom + baseOffset
}
viewBinding.dragHandle?.updateLayoutParams<MarginLayoutParams> {
bottomMargin = insets.top
viewBinding.scrollView.updatePadding(
bottom = insets.bottom,
)
viewBinding.containerBottomSheet?.let { bs ->
window.setNavigationBarTransparentCompat(this, bs.elevation, 0.9f)
}
}
private fun onHistoryChanged(info: HistoryInfo) {
with(viewBinding.buttonRead) {
if (info.history != null) {
setText(R.string._continue)
setIconResource(if (info.isIncognitoMode) R.drawable.ic_incognito else R.drawable.ic_play)
} else {
setText(R.string.read)
setIconResource(if (info.isIncognitoMode) R.drawable.ic_incognito else R.drawable.ic_play)
}
}
val text = when {
!info.isValid -> getString(R.string.loading_)
info.currentChapter >= 0 -> getString(
R.string.chapter_d_of_d,
info.currentChapter + 1,
info.totalChapters,
)
private fun onHistoryChanged(info: HistoryInfo, isLoading: Boolean) = with(viewBinding) {
buttonRead.setTitle(if (info.canContinue) R.string._continue else R.string.read)
buttonRead.subtitle = when {
isLoading -> getString(R.string.loading_)
info.isIncognitoMode -> getString(R.string.incognito_mode)
info.isChapterMissing -> getString(R.string.chapter_is_missing)
info.currentChapter >= 0 -> getString(R.string.chapter_d_of_d, info.currentChapter + 1, info.totalChapters)
info.totalChapters == 0 -> getString(R.string.no_chapters)
else -> resources.getQuantityString(
R.plurals.chapters,
info.totalChapters,
info.totalChapters,
)
info.totalChapters == -1 -> getString(R.string.error_occurred)
else -> resources.getQuantityString(R.plurals.chapters, info.totalChapters, info.totalChapters)
}
viewBinding.toolbarChapters?.title = text
viewBinding.textViewTitle?.text = text
buttonRead.setProgress(info.history?.percent?.coerceIn(0f, 1f) ?: 0f, true)
buttonDownload?.isEnabled = info.isValid && info.canDownload
buttonRead.isEnabled = info.isValid
}
private fun onNewChaptersChanged(count: Int) {
val tab = viewBinding.tabs.getTabAt(0) ?: return
if (count == 0) {
tab.removeBadge()
} else {
val badge = tab.orCreateBadge
badge.horizontalOffsetWithText = -resources.getDimensionPixelOffset(R.dimen.margin_small)
badge.number = count
badge.isVisible = true
}
chaptersBadge.counter = count
}
private fun showBranchPopupMenu(v: View) {
@@ -347,8 +586,11 @@ class DetailsActivity :
append(branch.count.toString())
}
}
menu.menu.add(Menu.NONE, Menu.NONE, i, title)
val item = menu.menu.add(R.id.group_branches, Menu.NONE, i, title)
item.isCheckable = true
item.isChecked = branch.isSelected
}
menu.menu.setGroupCheckable(R.id.group_branches, true, true)
menu.setOnMenuItemClickListener {
viewModel.setSelectedBranch(branches.getOrNull(it.order)?.name)
true
@@ -360,11 +602,11 @@ class DetailsActivity :
val manga = viewModel.manga.value ?: return
val chapterId = viewModel.historyInfo.value.history?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
Snackbar.make(viewBinding.containerDetails, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
Snackbar.make(viewBinding.scrollView, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
.show()
} else {
startActivity(
IntentBuilder(this)
ReaderActivity.IntentBuilder(this)
.manga(manga)
.branch(viewModel.selectedBranchValue)
.incognito(isIncognitoMode)
@@ -376,24 +618,45 @@ class DetailsActivity :
}
}
private fun initPager() {
val adapter = DetailsPagerAdapter(this, settings)
viewBinding.pager.recyclerView?.isNestedScrollingEnabled = false
viewBinding.pager.offscreenPageLimit = 1
viewBinding.pager.adapter = adapter
TabLayoutMediator(viewBinding.tabs, viewBinding.pager, adapter).attach()
viewBinding.pager.setCurrentItem(settings.defaultDetailsTab, false)
viewBinding.tabs.isVisible = adapter.itemCount > 1
private fun bindTags(manga: Manga) {
viewBinding.chipsTags.isVisible = manga.tags.isNotEmpty()
viewBinding.chipsTags.setChips(
manga.tags.map { tag ->
ChipsView.ChipModel(
title = tag.title,
tint = tagHighlighter.getTagTint(tag),
icon = 0,
data = tag,
isCheckable = false,
isChecked = false,
)
},
)
}
private fun showBottomSheet(isVisible: Boolean) {
val view = viewBinding.layoutBottom ?: return
if (view.isVisible == isVisible) return
val transition = Slide(Gravity.BOTTOM)
transition.addTarget(view)
transition.interpolator = AccelerateDecelerateInterpolator()
TransitionManager.beginDelayedTransition(viewBinding.root as ViewGroup, transition)
view.isVisible = isVisible
private fun loadCover(manga: Manga) {
val imageUrl = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }
val lastResult = CoilUtils.result(viewBinding.imageViewCover)
if (lastResult is SuccessResult && lastResult.request.data == imageUrl) {
return
}
val request = ImageRequest.Builder(this)
.target(viewBinding.imageViewCover)
.size(CoverSizeResolver(viewBinding.imageViewCover))
.data(imageUrl)
.tag(manga.source)
.crossfade(this)
.lifecycle(this)
.placeholderMemoryCacheKey(manga.coverUrl)
val previousDrawable = lastResult?.drawable
if (previousDrawable != null) {
request.fallback(previousDrawable)
.placeholder(previousDrawable)
.error(previousDrawable)
} else {
request.defaultPlaceholders(this)
}
request.enqueueWith(coil)
}
private class PrefetchObserver(
@@ -414,34 +677,18 @@ class DetailsActivity :
}
}
private fun showTip() {
val tip = ButtonTip(viewBinding.root as ViewGroup, insetsDelegate, viewModel)
tip.addToRoot()
buttonTip = WeakReference(tip)
}
companion object {
const val TIP_BUTTON = "btn_read"
private const val KEY_NEW_ACTIVITY = "new_details_screen"
private const val FAV_LABEL_LIMIT = 10
fun newIntent(context: Context, manga: Manga): Intent {
return getActivityIntent(context)
return Intent(context, DetailsActivity::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
}
fun newIntent(context: Context, mangaId: Long): Intent {
return getActivityIntent(context)
return Intent(context, DetailsActivity::class.java)
.putExtra(MangaIntent.KEY_ID, mangaId)
}
private fun getActivityIntent(context: Context): Intent {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val useNewActivity = prefs.getBoolean(KEY_NEW_ACTIVITY, false)
return Intent(
context,
if (useNewActivity) DetailsActivity2::class.java else DetailsActivity::class.java,
)
}
}
}

View File

@@ -1,703 +0,0 @@
package org.koitharu.kotatsu.details.ui
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.text.style.DynamicDrawableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.ImageSpan
import android.text.style.RelativeSizeSpan
import android.transition.TransitionManager
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewTreeObserver
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import coil.ImageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
import coil.transform.CircleCropTransformation
import coil.util.CoilUtils
import com.google.android.material.chip.Chip
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.iconResId
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ViewBadge
import org.koitharu.kotatsu.core.util.ext.crossfade
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.isTextTruncated
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ActivityDetailsNewBinding
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.data.ReadingTime
import org.koitharu.kotatsu.details.service.MangaPrefetchService
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet
import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.ellipsize
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint
class DetailsActivity2 :
BaseActivity<ActivityDetailsNewBinding>(),
View.OnClickListener,
View.OnLongClickListener, PopupMenu.OnMenuItemClickListener, View.OnLayoutChangeListener,
ViewTreeObserver.OnDrawListener, ChipsView.OnChipClickListener, OnListItemClickListener<Bookmark> {
@Inject
lateinit var shortcutManager: AppShortcutManager
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var tagHighlighter: ListExtraProvider
private val viewModel: DetailsViewModel by viewModels()
var bottomSheetMediator: ChaptersBottomSheetMediator? = null
private set
private lateinit var chaptersBadge: ViewBadge
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityDetailsNewBinding.inflate(layoutInflater))
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setDisplayShowTitleEnabled(false)
}
viewBinding.buttonRead.setOnClickListener(this)
viewBinding.buttonRead.setOnLongClickListener(this)
viewBinding.buttonRead.setOnContextClickListenerCompat(this)
viewBinding.buttonChapters.setOnClickListener(this)
viewBinding.infoLayout.chipBranch.setOnClickListener(this)
viewBinding.infoLayout.chipSize.setOnClickListener(this)
viewBinding.infoLayout.chipSource.setOnClickListener(this)
viewBinding.infoLayout.chipFavorite.setOnClickListener(this)
viewBinding.infoLayout.chipAuthor.setOnClickListener(this)
viewBinding.infoLayout.chipTime.setOnClickListener(this)
viewBinding.imageViewCover.setOnClickListener(this)
viewBinding.buttonDescriptionMore.setOnClickListener(this)
viewBinding.buttonScrobblingMore.setOnClickListener(this)
viewBinding.buttonRelatedMore.setOnClickListener(this)
viewBinding.infoLayout.chipSource.setOnClickListener(this)
viewBinding.infoLayout.chipSize.setOnClickListener(this)
viewBinding.textViewDescription.addOnLayoutChangeListener(this)
viewBinding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
viewBinding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
viewBinding.chipsTags.onChipClickListener = this
viewBinding.recyclerViewRelated.addItemDecoration(
SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing)),
)
TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView)
chaptersBadge = ViewBadge(viewBinding.buttonChapters, this)
viewModel.details.filterNotNull().observe(this, ::onMangaUpdated)
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
viewModel.onError.observeEvent(
this,
SnackbarErrorObserver(viewBinding.scrollView, null, exceptionResolver) {
if (it) viewModel.reload()
},
)
viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.scrollView, null))
viewModel.historyInfo.observe(this, ::onHistoryChanged)
viewModel.isLoading.observe(this, ::onLoadingStateChanged)
viewModel.scrobblingInfo.observe(this, ::onScrobblingInfoChanged)
viewModel.localSize.observe(this, ::onLocalSizeChanged)
viewModel.relatedManga.observe(this, ::onRelatedMangaChanged)
// viewModel.chapters.observe(this, ::onChaptersChanged)
viewModel.readingTime.observe(this, ::onReadingTimeChanged)
viewModel.selectedBranch.observe(this) {
viewBinding.infoLayout.chipBranch.text = it.ifNullOrEmpty { getString(R.string.system_default) }
}
viewModel.favouriteCategories.observe(this, ::onFavoritesChanged)
val menuInvalidator = MenuInvalidator(this)
viewModel.isStatsAvailable.observe(this, menuInvalidator)
viewModel.remoteManga.observe(this, menuInvalidator)
viewModel.branches.observe(this) {
viewBinding.infoLayout.chipBranch.isVisible = it.size > 1
}
viewModel.chapters.observe(this, PrefetchObserver(this))
viewModel.onDownloadStarted.observeEvent(
this,
DownloadStartedObserver(viewBinding.scrollView),
)
addMenuProvider(
DetailsMenuProvider(
activity = this,
viewModel = viewModel,
snackbarHost = viewBinding.scrollView,
appShortcutManager = shortcutManager,
),
)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_read -> openReader(isIncognitoMode = false)
R.id.chip_branch -> showBranchPopupMenu(v)
R.id.button_chapters -> {
ChaptersPagesSheet.show(supportFragmentManager)
}
R.id.chip_author -> {
val manga = viewModel.manga.value ?: return
startActivity(
SearchActivity.newIntent(
context = v.context,
source = manga.source,
query = manga.author ?: return,
),
)
}
R.id.chip_source -> {
val manga = viewModel.manga.value ?: return
startActivity(
MangaListActivity.newIntent(
context = v.context,
source = manga.source,
),
)
}
R.id.chip_size -> {
val manga = viewModel.manga.value ?: return
LocalInfoDialog.show(supportFragmentManager, manga)
}
R.id.chip_favorite -> {
val manga = viewModel.manga.value ?: return
FavoriteSheet.show(supportFragmentManager, manga)
}
R.id.chip_time -> {
if (viewModel.isStatsAvailable.value) {
val manga = viewModel.manga.value ?: return
MangaStatsSheet.show(supportFragmentManager, manga)
} else {
// TODO
}
}
R.id.imageView_cover -> {
val manga = viewModel.manga.value ?: return
startActivity(
ImageActivity.newIntent(
v.context,
manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl },
manga.source,
),
scaleUpActivityOptionsOf(v),
)
}
R.id.button_description_more -> {
val tv = viewBinding.textViewDescription
TransitionManager.beginDelayedTransition(tv.parentView)
if (tv.maxLines in 1 until Integer.MAX_VALUE) {
tv.maxLines = Integer.MAX_VALUE
} else {
tv.maxLines = resources.getInteger(R.integer.details_description_lines)
}
}
R.id.button_scrobbling_more -> {
val manga = viewModel.manga.value ?: return
ScrobblingSelectorSheet.show(supportFragmentManager, manga, null)
}
R.id.button_related_more -> {
val manga = viewModel.manga.value ?: return
startActivity(RelatedMangaActivity.newIntent(v.context, manga))
}
}
}
override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag ?: return
startActivity(MangaListActivity.newIntent(this, setOf(tag)))
}
override fun onLongClick(v: View): Boolean = when (v.id) {
R.id.button_read -> {
val menu = PopupMenu(v.context, v)
menu.inflate(R.menu.popup_read)
menu.menu.findItem(R.id.action_forget)?.isVisible = viewModel.historyInfo.value.run {
!isIncognitoMode && history != null
}
menu.setOnMenuItemClickListener(this)
menu.setForceShowIcon(true)
menu.show()
true
}
else -> false
}
override fun onMenuItemClick(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_incognito -> {
openReader(isIncognitoMode = true)
true
}
R.id.action_forget -> {
viewModel.removeFromHistory()
true
}
else -> false
}
}
override fun onItemClick(item: Bookmark, view: View) {
startActivity(
IntentBuilder(view.context).bookmark(item).incognito(true).build(),
)
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
override fun onDraw() {
viewBinding.run {
buttonDescriptionMore.isVisible = textViewDescription.maxLines == Int.MAX_VALUE ||
textViewDescription.isTextTruncated
}
}
override fun onLayoutChange(
v: View?,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int
) {
with(viewBinding) {
buttonDescriptionMore.isVisible = textViewDescription.isTextTruncated
}
}
private fun onChaptersChanged(chapters: List<ChapterListItem>?) {
// TODO
}
private fun onFavoritesChanged(categories: Set<FavouriteCategory>) {
val chip = viewBinding.infoLayout.chipFavorite
chip.setChipIconResource(if (categories.isEmpty()) R.drawable.ic_heart_outline else R.drawable.ic_heart)
chip.text = if (categories.isEmpty()) {
getString(R.string.add_to_favourites)
} else {
if (categories.size == 1) {
categories.first().title.ellipsize(FAV_LABEL_LIMIT)
}
buildString(FAV_LABEL_LIMIT + 6) {
for ((i, cat) in categories.withIndex()) {
if (i == 0) {
append(cat.title.ellipsize(FAV_LABEL_LIMIT - 4))
} else if (length + cat.title.length > FAV_LABEL_LIMIT) {
append(", ")
append(getString(R.string.list_ellipsize_pattern, categories.size - i))
break
} else {
append(", ")
append(cat.title)
}
}
}
}
}
private fun onReadingTimeChanged(time: ReadingTime?) {
val chip = viewBinding.infoLayout.chipTime
chip.textAndVisible = time?.formatShort(chip.resources)
}
private fun onDescriptionChanged(description: CharSequence?) {
val tv = viewBinding.textViewDescription
if (description.isNullOrBlank()) {
tv.setText(R.string.no_description)
} else {
tv.text = description
}
}
private fun onLocalSizeChanged(size: Long) {
val chip = viewBinding.infoLayout.chipSize
if (size == 0L) {
chip.isVisible = false
} else {
chip.text = FileSize.BYTES.format(chip.context, size)
chip.isVisible = true
}
}
private fun onRelatedMangaChanged(related: List<MangaItemModel>) {
if (related.isEmpty()) {
viewBinding.groupRelated.isVisible = false
return
}
val rv = viewBinding.recyclerViewRelated
@Suppress("UNCHECKED_CAST")
val adapter = (rv.adapter as? BaseListAdapter<ListModel>) ?: BaseListAdapter<ListModel>()
.addDelegate(
ListItemType.MANGA_GRID,
mangaGridItemAD(
coil, this,
StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)),
) { item, view ->
startActivity(DetailsActivity.newIntent(view.context, item))
},
).also { rv.adapter = it }
adapter.items = related
viewBinding.groupRelated.isVisible = true
}
private fun onLoadingStateChanged(isLoading: Boolean) {
val button = viewBinding.buttonChapters
if (isLoading) {
button.setImageDrawable(
CircularProgressDrawable(this).also {
it.setStyle(CircularProgressDrawable.LARGE)
it.setColorSchemeColors(getThemeColor(materialR.attr.colorControlNormal))
it.start()
},
)
} else {
button.setImageResource(R.drawable.ic_list_sheet)
}
}
private fun onScrobblingInfoChanged(scrobblings: List<ScrobblingInfo>) {
var adapter = viewBinding.recyclerViewScrobbling.adapter as? ScrollingInfoAdapter
viewBinding.groupScrobbling.isGone = scrobblings.isEmpty()
if (adapter != null) {
adapter.items = scrobblings
} else {
adapter = ScrollingInfoAdapter(this, coil, supportFragmentManager)
adapter.items = scrobblings
viewBinding.recyclerViewScrobbling.adapter = adapter
viewBinding.recyclerViewScrobbling.addItemDecoration(ScrobblingItemDecoration())
}
}
private fun onMangaUpdated(details: MangaDetails) {
with(viewBinding) {
val manga = details.toManga()
val hasChapters = !manga.chapters.isNullOrEmpty()
// Main
loadCover(manga)
textViewTitle.text = manga.title
textViewSubtitle.textAndVisible = manga.altTitle
infoLayout.chipAuthor.textAndVisible = manga.author
if (manga.hasRating) {
ratingBar.rating = manga.rating * ratingBar.numStars
ratingBar.isVisible = true
} else {
ratingBar.isVisible = false
}
manga.state?.let { state ->
textViewState.textAndVisible = resources.getString(state.titleResId)
imageViewState.setImageResource(state.iconResId)
} ?: run {
textViewState.isVisible = false
imageViewState.isVisible = false
}
if (manga.source == MangaSource.LOCAL || manga.source == MangaSource.DUMMY) {
infoLayout.chipSource.isVisible = false
} else {
infoLayout.chipSource.text = manga.source.title
infoLayout.chipSource.isVisible = true
}
textViewNsfw.isVisible = manga.isNsfw
// Chips
bindTags(manga)
textViewDescription.text = details.description.ifNullOrEmpty { getString(R.string.no_description) }
viewBinding.infoLayout.chipSource.also { chip ->
ImageRequest.Builder(this@DetailsActivity2)
.data(manga.source.faviconUri())
.lifecycle(this@DetailsActivity2)
.crossfade(false)
.size(resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
.target(ChipIconTarget(chip))
.placeholder(R.drawable.ic_web)
.fallback(R.drawable.ic_web)
.error(R.drawable.ic_web)
.source(manga.source)
.transformations(CircleCropTransformation())
.allowRgb565(true)
.enqueueWith(coil)
}
buttonChapters.isEnabled = hasChapters
title = manga.title
buttonRead.isEnabled = hasChapters
invalidateOptionsMenu()
}
}
private fun onMangaRemoved(manga: Manga) {
Toast.makeText(
this,
getString(R.string._s_deleted_from_local_storage, manga.title),
Toast.LENGTH_SHORT,
).show()
finishAfterTransition()
}
override fun onWindowInsetsChanged(insets: Insets) {
viewBinding.root.updatePadding(
left = insets.left,
right = insets.right,
bottom = insets.bottom
)
}
private fun onHistoryChanged(info: HistoryInfo) {
with(viewBinding.buttonRead) {
if (info.history != null) {
setTitle(R.string._continue)
} else {
setTitle(R.string.read)
}
}
viewBinding.buttonRead.subtitle = when {
!info.isValid -> getString(R.string.loading_)
info.currentChapter >= 0 -> getString(
R.string.chapter_d_of_d,
info.currentChapter + 1,
info.totalChapters,
)
info.totalChapters == 0 -> getString(R.string.no_chapters)
else -> resources.getQuantityString(
R.plurals.chapters,
info.totalChapters,
info.totalChapters,
)
}
viewBinding.buttonRead.setProgress(info.history?.percent?.coerceIn(0f, 1f) ?: 0f, true)
}
private fun onNewChaptersChanged(count: Int) {
chaptersBadge.counter = count
}
private fun showBranchPopupMenu(v: View) {
val menu = PopupMenu(v.context, v)
val branches = viewModel.branches.value
for ((i, branch) in branches.withIndex()) {
val title = buildSpannedString {
if (branch.isCurrent) {
inSpans(
ImageSpan(
this@DetailsActivity2,
R.drawable.ic_current_chapter,
DynamicDrawableSpan.ALIGN_BASELINE,
),
) {
append(' ')
}
append(' ')
}
append(branch.name ?: getString(R.string.system_default))
append(' ')
append(' ')
inSpans(
ForegroundColorSpan(
v.context.getThemeColor(
android.R.attr.textColorSecondary,
Color.LTGRAY,
),
),
RelativeSizeSpan(0.74f),
) {
append(branch.count.toString())
}
}
val item = menu.menu.add(R.id.group_branches, Menu.NONE, i, title)
item.isCheckable = true
item.isChecked = branch.isSelected
}
menu.menu.setGroupCheckable(R.id.group_branches, true, true)
menu.setOnMenuItemClickListener {
viewModel.setSelectedBranch(branches.getOrNull(it.order)?.name)
true
}
menu.show()
}
private fun openReader(isIncognitoMode: Boolean) {
val manga = viewModel.manga.value ?: return
val chapterId = viewModel.historyInfo.value.history?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
Snackbar.make(viewBinding.scrollView, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
.show()
} else {
startActivity(
IntentBuilder(this)
.manga(manga)
.branch(viewModel.selectedBranchValue)
.incognito(isIncognitoMode)
.build(),
)
if (isIncognitoMode) {
Toast.makeText(this, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
}
}
private fun bindTags(manga: Manga) {
viewBinding.chipsTags.isVisible = manga.tags.isNotEmpty()
viewBinding.chipsTags.setChips(
manga.tags.map { tag ->
ChipsView.ChipModel(
title = tag.title,
tint = tagHighlighter.getTagTint(tag),
icon = 0,
data = tag,
isCheckable = false,
isChecked = false,
)
},
)
}
private fun loadCover(manga: Manga) {
val imageUrl = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }
val lastResult = CoilUtils.result(viewBinding.imageViewCover)
if (lastResult is SuccessResult && lastResult.request.data == imageUrl) {
return
}
val request = ImageRequest.Builder(this)
.target(viewBinding.imageViewCover)
.size(CoverSizeResolver(viewBinding.imageViewCover))
.data(imageUrl)
.tag(manga.source)
.crossfade(this)
.lifecycle(this)
.placeholderMemoryCacheKey(manga.coverUrl)
val previousDrawable = lastResult?.drawable
if (previousDrawable != null) {
request.fallback(previousDrawable)
.placeholder(previousDrawable)
.error(previousDrawable)
} else {
request.fallback(R.drawable.ic_placeholder)
.placeholder(R.drawable.ic_placeholder)
.error(R.drawable.ic_error_placeholder)
}
request.enqueueWith(coil)
}
private class PrefetchObserver(
private val context: Context,
) : FlowCollector<List<ChapterListItem>?> {
private var isCalled = false
override suspend fun emit(value: List<ChapterListItem>?) {
if (value.isNullOrEmpty()) {
return
}
if (!isCalled) {
isCalled = true
val item = value.find { it.isCurrent } ?: value.first()
MangaPrefetchService.prefetchPages(context, item.chapter)
}
}
}
companion object {
private const val FAV_LABEL_LIMIT = 10
fun newIntent(context: Context, manga: Manga): Intent {
return Intent(context, DetailsActivity2::class.java)
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
}
fun newIntent(context: Context, mangaId: Long): Intent {
return Intent(context, DetailsActivity2::class.java)
.putExtra(MangaIntent.KEY_ID, mangaId)
}
}
}

View File

@@ -16,7 +16,7 @@ class DetailsErrorObserver(
private val viewModel: DetailsViewModel,
resolver: ExceptionResolver?,
) : ErrorObserver(
activity.viewBinding.containerDetails, null, resolver,
activity.viewBinding.scrollView, null, resolver,
{ isResolved ->
if (isResolved) {
viewModel.reload()

View File

@@ -1,424 +0,0 @@
package org.koitharu.kotatsu.details.ui
import android.os.Bundle
import android.transition.TransitionManager
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.text.method.LinkMovementMethodCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.fragment.app.activityViewModels
import coil.ImageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
import coil.util.CoilUtils
import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksSheet
import org.koitharu.kotatsu.core.model.countChaptersByBranch
import org.koitharu.kotatsu.core.model.iconResId
import org.koitharu.kotatsu.core.model.titleResId
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.crossfade
import org.koitharu.kotatsu.core.util.ext.drawableTop
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.isTextTruncated
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.data.ReadingTime
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import javax.inject.Inject
@AndroidEntryPoint
class DetailsFragment :
BaseFragment<FragmentDetailsBinding>(),
View.OnClickListener,
ChipsView.OnChipClickListener,
OnListItemClickListener<Bookmark>, ViewTreeObserver.OnDrawListener, View.OnLayoutChangeListener {
@Inject
lateinit var coil: ImageLoader
@Inject
lateinit var tagHighlighter: ListExtraProvider
private val viewModel by activityViewModels<DetailsViewModel>()
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = FragmentDetailsBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: FragmentDetailsBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
binding.textViewAuthor.setOnClickListener(this)
binding.imageViewCover.setOnClickListener(this)
binding.buttonDescriptionMore.setOnClickListener(this)
binding.buttonBookmarksMore.setOnClickListener(this)
binding.buttonScrobblingMore.setOnClickListener(this)
binding.buttonRelatedMore.setOnClickListener(this)
binding.infoLayout.textViewSource.setOnClickListener(this)
binding.infoLayout.textViewSize.setOnClickListener(this)
binding.textViewDescription.addOnLayoutChangeListener(this)
binding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
binding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
binding.chipsTags.onChipClickListener = this
binding.recyclerViewRelated.addItemDecoration(
SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing)),
)
TitleScrollCoordinator(binding.textViewTitle).attach(binding.scrollView)
viewModel.manga.filterNotNull().observe(viewLifecycleOwner, ::onMangaUpdated)
viewModel.isLoading.observe(viewLifecycleOwner, ::onLoadingStateChanged)
viewModel.historyInfo.observe(viewLifecycleOwner, ::onHistoryChanged)
viewModel.bookmarks.observe(viewLifecycleOwner, ::onBookmarksChanged)
viewModel.scrobblingInfo.observe(viewLifecycleOwner, ::onScrobblingInfoChanged)
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged)
viewModel.relatedManga.observe(viewLifecycleOwner, ::onRelatedMangaChanged)
viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged)
viewModel.readingTime.observe(viewLifecycleOwner, ::onReadingTimeChanged)
}
override fun onItemClick(item: Bookmark, view: View) {
startActivity(
ReaderActivity.IntentBuilder(view.context).bookmark(item).incognito(true).build(),
)
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
}
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
val menu = PopupMenu(view.context, view)
menu.inflate(R.menu.popup_bookmark)
menu.setOnMenuItemClickListener { menuItem ->
when (menuItem.itemId) {
R.id.action_remove -> viewModel.removeBookmark(item)
}
true
}
menu.show()
return true
}
override fun onDraw() {
viewBinding?.run {
buttonDescriptionMore.isVisible = textViewDescription.maxLines == Int.MAX_VALUE ||
textViewDescription.isTextTruncated
}
}
override fun onLayoutChange(
v: View?,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int
) {
with(viewBinding ?: return) {
buttonDescriptionMore.isVisible = textViewDescription.isTextTruncated
}
}
private fun onMangaUpdated(manga: Manga) {
with(requireViewBinding()) {
// Main
loadCover(manga)
textViewTitle.text = manga.title
textViewSubtitle.textAndVisible = manga.altTitle
textViewAuthor.textAndVisible = manga.author
if (manga.hasRating) {
ratingBar.rating = manga.rating * ratingBar.numStars
ratingBar.isVisible = true
} else {
ratingBar.isVisible = false
}
infoLayout.textViewState.apply {
manga.state?.let { state ->
textAndVisible = resources.getString(state.titleResId)
drawableTop = ContextCompat.getDrawable(context, state.iconResId)
} ?: run {
isVisible = false
}
}
if (manga.source == MangaSource.LOCAL || manga.source == MangaSource.DUMMY) {
infoLayout.textViewSource.isVisible = false
} else {
infoLayout.textViewSource.text = manga.source.title
infoLayout.textViewSource.isVisible = true
}
infoLayout.textViewNsfw.isVisible = manga.isNsfw
// Chips
bindTags(manga)
}
}
private fun onChaptersChanged(chapters: List<ChapterListItem>?) {
val infoLayout = requireViewBinding().infoLayout
if (chapters.isNullOrEmpty()) {
infoLayout.textViewChapters.isVisible = false
} else {
val count = chapters.countChaptersByBranch()
infoLayout.textViewChapters.isVisible = true
val chaptersText = resources.getQuantityString(R.plurals.chapters, count, count)
infoLayout.textViewChapters.text = chaptersText
}
}
private fun onReadingTimeChanged(time: ReadingTime?) {
val binding = viewBinding ?: return
if (time == null) {
binding.approximateReadTimeLayout.isVisible = false
return
}
binding.approximateReadTime.text = time.format(resources)
binding.approximateReadTimeTitle.setText(
if (time.isContinue) R.string.approximate_remaining_time else R.string.approximate_reading_time,
)
binding.approximateReadTimeLayout.isVisible = true
}
private fun onDescriptionChanged(description: CharSequence?) {
val tv = requireViewBinding().textViewDescription
if (description.isNullOrBlank()) {
tv.setText(R.string.no_description)
} else {
tv.text = description
}
}
private fun onLocalSizeChanged(size: Long) {
val textView = requireViewBinding().infoLayout.textViewSize
if (size == 0L) {
textView.isVisible = false
} else {
textView.text = FileSize.BYTES.format(textView.context, size)
textView.isVisible = true
}
}
private fun onRelatedMangaChanged(related: List<MangaItemModel>) {
if (related.isEmpty()) {
requireViewBinding().groupRelated.isVisible = false
return
}
val rv = viewBinding?.recyclerViewRelated ?: return
@Suppress("UNCHECKED_CAST")
val adapter = (rv.adapter as? BaseListAdapter<ListModel>) ?: BaseListAdapter<ListModel>()
.addDelegate(
ListItemType.MANGA_GRID,
mangaGridItemAD(
coil, viewLifecycleOwner,
StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)),
) { item, view ->
startActivity(DetailsActivity.newIntent(view.context, item))
},
).also { rv.adapter = it }
adapter.items = related
requireViewBinding().groupRelated.isVisible = true
}
private fun onHistoryChanged(history: HistoryInfo) {
requireViewBinding().progressView.setPercent(history.history?.percent ?: PROGRESS_NONE, animate = true)
}
private fun onLoadingStateChanged(isLoading: Boolean) {
requireViewBinding().progressBar.showOrHide(isLoading)
}
private fun onBookmarksChanged(bookmarks: List<Bookmark>) {
var adapter = requireViewBinding().recyclerViewBookmarks.adapter as? BookmarksAdapter
requireViewBinding().groupBookmarks.isGone = bookmarks.isEmpty()
if (adapter != null) {
adapter.items = bookmarks
} else {
adapter = BookmarksAdapter(coil, viewLifecycleOwner, this)
adapter.items = bookmarks
requireViewBinding().recyclerViewBookmarks.adapter = adapter
val spacing = resources.getDimensionPixelOffset(R.dimen.bookmark_list_spacing)
requireViewBinding().recyclerViewBookmarks.addItemDecoration(SpacingItemDecoration(spacing))
}
}
private fun onScrobblingInfoChanged(scrobblings: List<ScrobblingInfo>) {
var adapter = requireViewBinding().recyclerViewScrobbling.adapter as? ScrollingInfoAdapter
requireViewBinding().groupScrobbling.isGone = scrobblings.isEmpty()
if (adapter != null) {
adapter.items = scrobblings
} else {
adapter = ScrollingInfoAdapter(viewLifecycleOwner, coil, childFragmentManager)
adapter.items = scrobblings
requireViewBinding().recyclerViewScrobbling.adapter = adapter
requireViewBinding().recyclerViewScrobbling.addItemDecoration(ScrobblingItemDecoration())
}
}
override fun onClick(v: View) {
val manga = viewModel.manga.value ?: return
when (v.id) {
R.id.textView_author -> {
startActivity(
SearchActivity.newIntent(
context = v.context,
source = manga.source,
query = manga.author ?: return,
),
)
}
R.id.textView_source -> {
startActivity(
MangaListActivity.newIntent(
context = v.context,
source = manga.source,
),
)
}
R.id.textView_size -> {
LocalInfoDialog.show(parentFragmentManager, manga)
}
R.id.imageView_cover -> {
startActivity(
ImageActivity.newIntent(
v.context,
manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl },
manga.source,
),
scaleUpActivityOptionsOf(v),
)
}
R.id.button_description_more -> {
val tv = requireViewBinding().textViewDescription
TransitionManager.beginDelayedTransition(tv.parentView)
if (tv.maxLines in 1 until Integer.MAX_VALUE) {
tv.maxLines = Integer.MAX_VALUE
} else {
tv.maxLines = resources.getInteger(R.integer.details_description_lines)
}
}
R.id.button_scrobbling_more -> {
ScrobblingSelectorSheet.show(parentFragmentManager, manga, null)
}
R.id.button_bookmarks_more -> {
BookmarksSheet.show(parentFragmentManager, manga)
}
R.id.button_related_more -> {
startActivity(RelatedMangaActivity.newIntent(v.context, manga))
}
}
}
override fun onChipClick(chip: Chip, data: Any?) {
val tag = data as? MangaTag ?: return
startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag)))
}
override fun onWindowInsetsChanged(insets: Insets) {
requireViewBinding().root.updatePadding(
bottom = (
(activity as? NoModalBottomSheetOwner)?.getBottomSheetCollapsedHeight()
?.plus(insets.bottom)?.plus(resources.resolveDp(16))
)
?: insets.bottom,
)
}
private fun bindTags(manga: Manga) {
requireViewBinding().chipsTags.setChips(
manga.tags.map { tag ->
ChipsView.ChipModel(
title = tag.title,
tint = tagHighlighter.getTagTint(tag),
icon = 0,
data = tag,
isCheckable = false,
isChecked = false,
)
},
)
}
private fun loadCover(manga: Manga) {
val imageUrl = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }
val lastResult = CoilUtils.result(requireViewBinding().imageViewCover)
if (lastResult is SuccessResult && lastResult.request.data == imageUrl) {
return
}
val request = ImageRequest.Builder(context ?: return)
.target(requireViewBinding().imageViewCover)
.size(CoverSizeResolver(requireViewBinding().imageViewCover))
.data(imageUrl)
.tag(manga.source)
.crossfade(requireContext())
.lifecycle(viewLifecycleOwner)
.placeholderMemoryCacheKey(manga.coverUrl)
val previousDrawable = lastResult?.drawable
if (previousDrawable != null) {
request.fallback(previousDrawable)
.placeholder(previousDrawable)
.error(previousDrawable)
} else {
request.fallback(R.drawable.ic_placeholder)
.placeholder(R.drawable.ic_placeholder)
.error(R.drawable.ic_error_placeholder)
}
request.enqueueWith(coil)
}
}

View File

@@ -19,8 +19,8 @@ import org.koitharu.kotatsu.browser.BrowserActivity
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
@@ -35,7 +35,6 @@ class DetailsMenuProvider(
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_details, menu)
menu.findItem(R.id.action_favourite).isVisible = activity is DetailsActivity
}
override fun onPrepareMenu(menu: Menu) {
@@ -48,9 +47,6 @@ class DetailsMenuProvider(
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null
menu.findItem(R.id.action_stats).isVisible = viewModel.isStatsAvailable.value
menu.findItem(R.id.action_favourite).setIcon(
if (viewModel.favouriteCategories.value.isNotEmpty()) R.drawable.ic_heart else R.drawable.ic_heart_outline,
)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
@@ -66,12 +62,6 @@ class DetailsMenuProvider(
}
}
R.id.action_favourite -> {
viewModel.manga.value?.let {
FavoriteSheet.show(activity.supportFragmentManager, it)
}
}
R.id.action_delete -> {
val title = viewModel.manga.value?.title.orEmpty()
MaterialAlertDialogBuilder(activity)
@@ -145,6 +135,7 @@ class DetailsMenuProvider(
is DownloadOption.WholeManga -> null
is DownloadOption.SelectionHint -> {
viewModel.startChaptersSelection()
ChaptersPagesSheet.show(activity.supportFragmentManager, ChaptersPagesSheet.TAB_CHAPTERS)
return
}

View File

@@ -11,8 +11,6 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
@@ -86,8 +84,8 @@ class DetailsViewModel @Inject constructor(
) : BaseViewModel() {
private val intent = MangaIntent(savedStateHandle)
private val mangaId = intent.mangaId
private var loadingJob: Job
val mangaId = intent.mangaId
val onActionDone = MutableEventFlow<ReversibleAction>()
val onShowTip = MutableEventFlow<Unit>()
@@ -133,7 +131,7 @@ class DetailsViewModel @Inject constructor(
)
val historyInfo: StateFlow<HistoryInfo> = combine(
manga,
details,
selectedBranch,
history,
interactor.observeIncognitoMode(manga),
@@ -247,12 +245,6 @@ class DetailsViewModel @Inject constructor(
localStorageChanges
.collect { onDownloadComplete(it) }
}
launchJob(Dispatchers.Default) {
if (settings.isTipEnabled(DetailsActivity.TIP_BUTTON)) {
manga.filterNot { it?.chapters.isNullOrEmpty() }.first()
onShowTip.call(Unit)
}
}
launchJob(Dispatchers.Default) {
val manga = details.firstOrNull { !it?.chapters.isNullOrEmpty() } ?: return@launchJob
val h = history.firstOrNull()
@@ -363,10 +355,6 @@ class DetailsViewModel @Inject constructor(
onSelectChapter.call(chapter.chapter.id)
}
fun onButtonTipClosed() {
settings.closeTip(DetailsActivity.TIP_BUTTON)
}
fun removeFromHistory() {
launchJob(Dispatchers.Default) {
val handle = historyRepository.delete(setOf(mangaId))

View File

@@ -28,7 +28,7 @@ class DownloadDialogHelper(
}
if (history != null) {
val unreadChapters = branchChapters.takeLastWhile { it.id != history.chapterId }
val unreadChapters = branchChapters.dropWhile { it.id != history.chapterId }
if (unreadChapters.isNotEmpty() && unreadChapters.size < branchChapters.size) {
add(DownloadOption.AllUnreadChapters(unreadChapters.ids(), branch))
if (unreadChapters.size > 5) {

View File

@@ -13,13 +13,13 @@ import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemChapterBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.list.ui.model.ListModel
import com.google.android.material.R as MR
import com.google.android.material.R as materialR
fun chapterListItemAD(
clickListener: OnListItemClickListener<ChapterListItem>,
) = adapterDelegateViewBinding<ChapterListItem, ListModel, ItemChapterBinding>(
viewBinding = { inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) },
on = { item, _, _ -> item is ChapterListItem && !item.isGrid }
on = { item, _, _ -> item is ChapterListItem && !item.isGrid },
) {
val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener)
@@ -27,10 +27,17 @@ fun chapterListItemAD(
itemView.setOnLongClickListener(eventListener)
bind { payloads ->
if (payloads.isEmpty()) {
binding.textViewTitle.text = item.chapter.name
binding.textViewDescription.textAndVisible = item.description
}
binding.textViewTitle.text = item.chapter.name
binding.textViewDescription.textAndVisible = item.description
itemView.setBackgroundResource(
when {
item.isGroupStart && item.isGroupEnd -> R.drawable.bg_card_full
item.isGroupStart -> R.drawable.bg_card_top
item.isGroupMiddle -> R.drawable.bg_card_none
item.isGroupEnd -> R.drawable.bg_card_bottom
else -> R.drawable.list_selector
},
)
when {
item.isCurrent -> {
binding.textViewTitle.drawableStart = ContextCompat.getDrawable(context, R.drawable.ic_current_chapter)
@@ -47,7 +54,7 @@ fun chapterListItemAD(
null
}
binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary))
binding.textViewDescription.setTextColor(context.getThemeColorStateList(MR.attr.colorOutline))
binding.textViewDescription.setTextColor(context.getThemeColorStateList(materialR.attr.colorOutline))
binding.textViewTitle.typeface = Typeface.DEFAULT
binding.textViewDescription.typeface = Typeface.DEFAULT
}

View File

@@ -7,7 +7,6 @@ import android.graphics.Paint
import android.graphics.RectF
import android.view.View
import androidx.cardview.widget.CardView
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
@@ -20,10 +19,7 @@ import com.google.android.material.R as materialR
class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val radius = context.resources.getDimension(materialR.dimen.abc_control_corner_material)
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.chapter_check_offset)
private val iconSize = context.resources.getDimensionPixelOffset(R.dimen.chapter_check_size)
private val defaultRadius = context.resources.getDimension(materialR.dimen.abc_control_corner_material)
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
private val fillColor = ColorUtils.setAlphaComponent(
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
@@ -36,12 +32,11 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor
98,
)
paint.style = Paint.Style.FILL
hasBackground = true
hasBackground = false
hasForeground = true
isIncludeDecorAndMargins = false
paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width)
checkIcon?.setTint(strokeColor)
}
override fun getItemId(parent: RecyclerView, child: View): Long {
@@ -50,19 +45,6 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor
return item.chapter.id
}
override fun onDrawBackground(
canvas: Canvas,
parent: RecyclerView,
child: View,
bounds: RectF,
state: RecyclerView.State,
) {
if (child is CardView) {
return
}
canvas.drawRoundRect(bounds, radius, radius, paint)
}
override fun onDrawForeground(
canvas: Canvas,
parent: RecyclerView,
@@ -70,24 +52,16 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor
bounds: RectF,
state: RecyclerView.State
) {
if (child !is CardView) {
return
val radius = if (child is CardView) {
child.radius
} else {
defaultRadius
}
val radius = child.radius
paint.color = fillColor
paint.style = Paint.Style.FILL
canvas.drawRoundRect(bounds, radius, radius, paint)
paint.color = strokeColor
paint.style = Paint.Style.STROKE
canvas.drawRoundRect(bounds, radius, radius, paint)
checkIcon?.run {
setBounds(
(bounds.right - iconSize - iconOffset).toInt(),
(bounds.top + iconOffset).toInt(),
(bounds.right - iconOffset).toInt(),
(bounds.top + iconOffset + iconSize).toInt(),
)
draw(canvas)
}
}
}

View File

@@ -5,11 +5,13 @@ import org.jsoup.internal.StringUtil.StringJoiner
import org.koitharu.kotatsu.core.model.formatNumber
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaChapter
import kotlin.experimental.and
data class ChapterListItem(
val chapter: MangaChapter,
val flags: Int,
val flags: Byte,
private val uploadDateMs: Long,
private val groupPosition: Byte,
) : ListModel {
var description: String? = null
@@ -51,6 +53,15 @@ data class ChapterListItem(
val isGrid: Boolean
get() = hasFlag(FLAG_GRID)
val isGroupStart: Boolean
get() = (groupPosition and GROUP_START) == GROUP_START
val isGroupMiddle: Boolean
get() = (groupPosition and GROUP_MIDDLE) == GROUP_MIDDLE
val isGroupEnd: Boolean
get() = (groupPosition and GROUP_END) == GROUP_END
private fun buildDescription(): String {
val joiner = StringJoiner("")
chapter.formatNumber()?.let {
@@ -67,7 +78,7 @@ data class ChapterListItem(
return joiner.complete()
}
private fun hasFlag(flag: Int): Boolean {
private fun hasFlag(flag: Byte): Boolean {
return (flags and flag) == flag
}
@@ -88,11 +99,15 @@ data class ChapterListItem(
companion object {
const val FLAG_UNREAD = 2
const val FLAG_CURRENT = 4
const val FLAG_NEW = 8
const val FLAG_BOOKMARKED = 16
const val FLAG_DOWNLOADED = 32
const val FLAG_GRID = 64
const val FLAG_UNREAD: Byte = 2
const val FLAG_CURRENT: Byte = 4
const val FLAG_NEW: Byte = 8
const val FLAG_BOOKMARKED: Byte = 16
const val FLAG_DOWNLOADED: Byte = 32
const val FLAG_GRID: Byte = 64
const val GROUP_START: Byte = 2
const val GROUP_MIDDLE: Byte = 4
const val GROUP_END: Byte = 8
}
}

View File

@@ -1,6 +1,9 @@
package org.koitharu.kotatsu.details.ui.model
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.parsers.model.Manga
data class HistoryInfo(
@@ -8,26 +11,34 @@ data class HistoryInfo(
val currentChapter: Int,
val history: MangaHistory?,
val isIncognitoMode: Boolean,
val isChapterMissing: Boolean,
val canDownload: Boolean,
) {
val isValid: Boolean
get() = totalChapters >= 0
val canContinue: Boolean
get() = history != null && !isChapterMissing
}
fun HistoryInfo(
manga: Manga?,
manga: MangaDetails?,
branch: String?,
history: MangaHistory?,
isIncognitoMode: Boolean
): HistoryInfo {
val chapters = manga?.getChapters(branch)
val chapters = manga?.chapters?.get(branch)
val currentChapter = if (history != null && !chapters.isNullOrEmpty()) {
chapters.indexOfFirst { it.id == history.chapterId }
} else {
-2
}
return HistoryInfo(
totalChapters = chapters?.size ?: -1,
currentChapter = if (history != null && !chapters.isNullOrEmpty()) {
chapters.indexOfFirst { it.id == history.chapterId }
} else {
-1
},
currentChapter = currentChapter,
history = history,
isIncognitoMode = isIncognitoMode,
isChapterMissing = currentChapter == -1,
canDownload = manga?.isLocal == false,
)
}

View File

@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_GRID
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_UNREAD
import org.koitharu.kotatsu.parsers.model.MangaChapter
import kotlin.experimental.or
fun MangaChapter.toListItem(
isCurrent: Boolean,
@@ -16,7 +17,7 @@ fun MangaChapter.toListItem(
isBookmarked: Boolean,
isGrid: Boolean,
): ChapterListItem {
var flags = 0
var flags: Byte = 0
if (isCurrent) flags = flags or FLAG_CURRENT
if (isUnread) flags = flags or FLAG_UNREAD
if (isNew) flags = flags or FLAG_NEW
@@ -27,5 +28,6 @@ fun MangaChapter.toListItem(
chapter = this,
flags = flags,
uploadDateMs = uploadDate,
groupPosition = 0,
)
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.details.ui
package org.koitharu.kotatsu.details.ui.pager
import android.view.Menu
import android.view.MenuInflater
@@ -14,6 +14,8 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_BOOKMARKS
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_CHAPTERS
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_PAGES
import java.lang.ref.WeakReference
@@ -42,7 +44,7 @@ class ChapterPagesMenuProvider(
menu.findItem(R.id.action_grid_view)?.isChecked = viewModel.isChaptersInGridView.value == true
}
TAB_PAGES -> {
TAB_PAGES, TAB_BOOKMARKS -> {
menuInflater.inflate(R.menu.opt_pages, menu)
menu.findItem(R.id.action_grid_size)?.run {
setOnActionExpandListener(this@ChapterPagesMenuProvider)

View File

@@ -5,12 +5,11 @@ import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.details.ui.pager.bookmarks.MangaBookmarksFragment
import org.koitharu.kotatsu.details.ui.pager.chapters.ChaptersFragment
import org.koitharu.kotatsu.details.ui.pager.pages.PagesFragment
class DetailsPagerAdapter2(
class ChaptersPagesAdapter(
fragment: Fragment,
val isPagesTabEnabled: Boolean,
) : FragmentStateAdapter(fragment),

View File

@@ -2,29 +2,40 @@ package org.koitharu.kotatsu.details.ui.pager
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ActionMode
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior.Companion.STATE_COLLAPSED
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior.Companion.STATE_DRAGGING
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior.Companion.STATE_EXPANDED
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior.Companion.STATE_SETTLING
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
import org.koitharu.kotatsu.core.util.ext.menuView
import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setTabsEnabled
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetChaptersPagesBinding
import org.koitharu.kotatsu.details.ui.ChapterPagesMenuProvider
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import javax.inject.Inject
@AndroidEntryPoint
class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(), ActionModeListener {
class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(), ActionModeListener, AdaptiveSheetCallback {
@Inject
lateinit var settings: AppSettings
@@ -40,30 +51,45 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(), Actio
disableFitToContents()
val args = arguments ?: Bundle.EMPTY
val adapter = DetailsPagerAdapter2(this, args.getBoolean(ARG_SHOW_PAGES, settings.isPagesTabEnabled))
binding.pager.recyclerView?.isNestedScrollingEnabled = false
val defaultTab = args.getInt(ARG_TAB, settings.defaultDetailsTab)
val adapter = ChaptersPagesAdapter(this, settings.isPagesTabEnabled || defaultTab == TAB_PAGES)
binding.pager.offscreenPageLimit = adapter.itemCount
binding.pager.adapter = adapter
binding.pager.doOnPageChanged(::onPageChanged)
TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
binding.pager.setCurrentItem(args.getInt(ARG_TAB, settings.defaultDetailsTab), false)
binding.pager.setCurrentItem(defaultTab, false)
binding.tabs.isVisible = adapter.itemCount > 1
val menuProvider = ChapterPagesMenuProvider(viewModel, this, binding.pager, settings)
onBackPressedDispatcher.addCallback(viewLifecycleOwner, menuProvider)
binding.toolbar.addMenuProvider(menuProvider)
actionModeDelegate.addListener(this, viewLifecycleOwner)
actionModeDelegate?.addListener(this, viewLifecycleOwner)
addSheetCallback(this, viewLifecycleOwner)
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.pager, this))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.pager, null))
viewModel.onDownloadStarted.observeEvent(viewLifecycleOwner, DownloadStartedObserver(binding.pager))
viewModel.newChaptersCount.observe(viewLifecycleOwner, ::onNewChaptersChanged)
}
override fun onStateChanged(sheet: View, newState: Int) {
if (newState == STATE_DRAGGING || newState == STATE_SETTLING) {
return
}
val isActionModeStarted = actionModeDelegate?.isActionModeStarted == true
viewBinding?.toolbar?.menuView?.isVisible = newState != STATE_COLLAPSED && !isActionModeStarted
}
override fun onActionModeStarted(mode: ActionMode) {
expandAndLock()
viewBinding?.toolbar?.menuView?.isEnabled = false
viewBinding?.toolbar?.menuView?.isVisible = false
}
override fun onActionModeFinished(mode: ActionMode) {
unlock()
viewBinding?.toolbar?.menuView?.isEnabled = true
val state = behavior?.state ?: STATE_EXPANDED
viewBinding?.toolbar?.menuView?.isVisible = state != STATE_COLLAPSED
}
override fun expandAndLock() {
@@ -85,6 +111,17 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(), Actio
private fun onPageChanged(position: Int) {
viewBinding?.toolbar?.invalidateMenu()
settings.lastDetailsTab = position
}
private fun onNewChaptersChanged(counter: Int) {
val tab = viewBinding?.tabs?.getTabAt(0) ?: return
if (counter == 0) {
tab.removeBadge()
} else {
val badge = tab.orCreateBadge
badge.number = counter
}
}
companion object {
@@ -93,6 +130,8 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(), Actio
const val TAB_PAGES = 1
const val TAB_BOOKMARKS = 2
private const val ARG_TAB = "tag"
@Deprecated("")
private const val ARG_SHOW_PAGES = "pages"
private const val TAG = "ChaptersPagesSheet"
@@ -100,17 +139,15 @@ class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(), Actio
ChaptersPagesSheet().showDistinct(fm, TAG)
}
fun show(fm: FragmentManager, showPagesTab: Boolean) {
fun show(fm: FragmentManager, defaultTab: Int) {
ChaptersPagesSheet().withArgs(1) {
putBoolean(ARG_SHOW_PAGES, showPagesTab)
}.showDistinct(fm, TAG)
}
fun show(fm: FragmentManager, showPagesTab: Boolean, defaultTab: Int) {
ChaptersPagesSheet().withArgs(2) {
putBoolean(ARG_SHOW_PAGES, showPagesTab)
putInt(ARG_TAB, defaultTab)
}.showDistinct(fm, TAG)
}
fun isShown(fm: FragmentManager): Boolean {
val sheet = fm.findFragmentByTag(TAG) as? ChaptersPagesSheet
return sheet != null && sheet.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
}
}
}

View File

@@ -1,38 +0,0 @@
package org.koitharu.kotatsu.details.ui.pager
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.details.ui.pager.chapters.ChaptersFragment
import org.koitharu.kotatsu.details.ui.pager.pages.PagesFragment
class DetailsPagerAdapter(
activity: FragmentActivity,
settings: AppSettings,
) : FragmentStateAdapter(activity),
TabLayoutMediator.TabConfigurationStrategy {
val isPagesTabEnabled = settings.isPagesTabEnabled
override fun getItemCount(): Int = if (isPagesTabEnabled) 2 else 1
override fun createFragment(position: Int): Fragment = when (position) {
0 -> ChaptersFragment()
1 -> PagesFragment()
else -> throw IllegalArgumentException("Invalid position $position")
}
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
tab.setText(
when (position) {
0 -> R.string.chapters
1 -> R.string.pages
else -> 0
},
)
}
}

View File

@@ -20,14 +20,11 @@ import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentMangaBookmarksBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
import org.koitharu.kotatsu.list.ui.GridSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
import javax.inject.Inject
@AndroidEntryPoint
@@ -44,7 +41,7 @@ class MangaBookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
lateinit var settings: AppSettings
private var bookmarksAdapter: BookmarksAdapter? = null
private var spanResolver: MangaListSpanResolver? = null
private var spanResolver: GridSpanResolver? = null
private val spanSizeLookup = SpanSizeLookup()
private val listCommitCallback = Runnable {
@@ -62,19 +59,22 @@ class MangaBookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
override fun onViewBindingCreated(binding: FragmentMangaBookmarksBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
spanResolver = MangaListSpanResolver(binding.root.resources)
spanResolver = GridSpanResolver(binding.root.resources)
bookmarksAdapter = BookmarksAdapter(
coil = coil,
lifecycleOwner = viewLifecycleOwner,
clickListener = this@MangaBookmarksFragment,
headerClickListener = null,
)
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) // before rv initialization
with(binding.recyclerView) {
addItemDecoration(TypedListSpacingDecoration(context, false))
adapter = bookmarksAdapter
addOnLayoutChangeListener(spanResolver)
spanResolver?.setGridSize(settings.gridSize / 100f, this)
(layoutManager as GridLayoutManager).spanSizeLookup = spanSizeLookup
(layoutManager as GridLayoutManager).let {
it.spanSizeLookup = spanSizeLookup
it.spanCount = checkNotNull(spanResolver).spanCount
}
}
viewModel.content.observe(viewLifecycleOwner, checkNotNull(bookmarksAdapter))
}
@@ -113,6 +113,11 @@ class MangaBookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
}
}
private fun onGridScaleChanged(scale: Float) {
spanSizeLookup.invalidateCache()
spanResolver?.setGridSize(scale, requireViewBinding().recyclerView)
}
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {
init {

View File

@@ -15,6 +15,8 @@ import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader
@@ -26,10 +28,17 @@ import javax.inject.Inject
@HiltViewModel
class MangaBookmarksViewModel @Inject constructor(
bookmarksRepository: BookmarksRepository,
settings: AppSettings,
) : BaseViewModel(), FlowCollector<Manga?> {
private val manga = MutableStateFlow<Manga?>(null)
val gridScale = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_GRID_SIZE_PAGES,
valueProducer = { gridSizePages / 100f },
)
val content: StateFlow<List<ListModel>> = manga.filterNotNull().flatMapLatest { m ->
bookmarksRepository.observeBookmarks(m)
.map { mapList(m, it) }

View File

@@ -10,11 +10,11 @@ import androidx.appcompat.view.ActionMode
import androidx.core.graphics.Insets
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.R
@@ -28,8 +28,6 @@ import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.ChaptersMenuProvider
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
@@ -93,14 +91,11 @@ class ChaptersFragment :
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
binding.textViewHolder.isVisible = it
}
viewModel.onSelectChapter.observeEvent(viewLifecycleOwner) {
selectionController?.onItemLongClick(it)
}
val detailsActivity = activity as? DetailsActivity
if (detailsActivity != null) {
val menuProvider = ChaptersMenuProvider(viewModel, detailsActivity.bottomSheetMediator)
activity?.onBackPressedDispatcher?.addCallback(menuProvider)
detailsActivity.secondaryMenuHost.addMenuProvider(menuProvider, viewLifecycleOwner, Lifecycle.State.RESUMED)
viewModel.onSelectChapter.observeEvent(viewLifecycleOwner) { chapterId ->
chaptersAdapter?.observeItems()?.firstOrNull { items ->
items.any { x -> x is ChapterListItem && x.chapter.id == chapterId }
}
selectionController?.onItemLongClick(chapterId)
}
}

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.reader.ui.thumbnails
package org.koitharu.kotatsu.details.ui.pager.pages
import android.content.Context
import android.webkit.MimeTypeMap

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.reader.ui.thumbnails
package org.koitharu.kotatsu.details.ui.pager.pages
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.reader.ui.thumbnails.adapter
package org.koitharu.kotatsu.details.ui.pager.pages
import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
@@ -9,13 +9,13 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.decodeRegion
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.setTextColorAttr
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemPageThumbBinding
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
import com.google.android.material.R as materialR
fun pageThumbnailAD(
@@ -39,9 +39,7 @@ fun pageThumbnailAD(
bind {
val data: Any = item.page.preview?.takeUnless { it.isEmpty() } ?: item.page.toMangaPage()
binding.imageViewThumb.newImageRequest(lifecycleOwner, data)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
defaultPlaceholders(context)
size(thumbSize)
scale(Scale.FILL)
allowRgb565(true)

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.reader.ui.thumbnails.adapter
package org.koitharu.kotatsu.details.ui.pager.pages
import android.content.Context
import androidx.lifecycle.LifecycleOwner
@@ -9,7 +9,6 @@ import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
class PageThumbnailAdapter(
coil: ImageLoader,

View File

@@ -30,14 +30,13 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.databinding.FragmentPagesBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.list.ui.GridSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter
import javax.inject.Inject
import kotlin.math.roundToInt
@@ -56,7 +55,7 @@ class PagesFragment :
lateinit var settings: AppSettings
private var thumbnailsAdapter: PageThumbnailAdapter? = null
private var spanResolver: PagesGridSpanResolver? = null
private var spanResolver: GridSpanResolver? = null
private var scrollListener: ScrollListener? = null
private val spanSizeLookup = SpanSizeLookup()
@@ -83,7 +82,7 @@ class PagesFragment :
override fun onViewBindingCreated(binding: FragmentPagesBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
spanResolver = PagesGridSpanResolver(binding.root.resources)
spanResolver = GridSpanResolver(binding.root.resources)
thumbnailsAdapter = PageThumbnailAdapter(
coil = coil,
lifecycleOwner = viewLifecycleOwner,

View File

@@ -1,59 +0,0 @@
package org.koitharu.kotatsu.details.ui.pager.pages
import android.content.res.Resources
import android.view.View
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R
import kotlin.math.abs
import kotlin.math.roundToInt
class PagesGridSpanResolver(
resources: Resources,
) : View.OnLayoutChangeListener {
var spanCount = 3
private set
private val gridWidth = resources.getDimension(R.dimen.preferred_grid_width)
private val spacing = resources.getDimension(R.dimen.grid_spacing)
private var cellWidth = -1f
override fun onLayoutChange(
v: View?,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int,
) {
if (cellWidth <= 0f) {
return
}
val rv = v as? RecyclerView ?: return
val width = abs(right - left)
if (width == 0) {
return
}
resolveGridSpanCount(width)
(rv.layoutManager as? GridLayoutManager)?.spanCount = spanCount
}
fun setGridSize(scaleFactor: Float, rv: RecyclerView) {
cellWidth = (gridWidth * scaleFactor) + spacing
val lm = rv.layoutManager as? GridLayoutManager ?: return
val innerWidth = lm.width - lm.paddingEnd - lm.paddingStart
if (innerWidth > 0) {
resolveGridSpanCount(innerWidth)
lm.spanCount = spanCount
}
}
private fun resolveGridSpanCount(width: Int) {
val estimatedCount = (width / cellWidth).roundToInt()
spanCount = estimatedCount.coerceAtLeast(2)
}
}

View File

@@ -16,7 +16,6 @@ import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.domain.ChaptersLoader
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
import javax.inject.Inject
@HiltViewModel
@@ -76,7 +75,9 @@ class PagesViewModel @Inject constructor(
private suspend fun doInit(state: State) {
chaptersLoader.init(state.details)
val initialChapterId = state.history?.chapterId ?: state.details.allChapters.firstOrNull()?.id ?: return
val initialChapterId = state.history?.chapterId?.takeIf {
chaptersLoader.peekChapter(it) != null
} ?: state.details.allChapters.firstOrNull()?.id ?: return
if (!chaptersLoader.hasPages(initialChapterId)) {
chaptersLoader.loadSingleChapter(initialChapterId)
}

View File

@@ -5,6 +5,7 @@ import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.databinding.ItemScrobblingInfoBinding
@@ -24,9 +25,7 @@ fun scrobblingInfoAD(
bind {
binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
defaultPlaceholders(context)
enqueueWith(coil)
}
binding.textViewTitle.setText(item.scrobbler.titleResId)

View File

@@ -18,6 +18,7 @@ import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.newImageRequest
@@ -129,9 +130,7 @@ class ScrobblingInfoSheet :
binding.imageViewLogo.contentDescription = getString(scrobbling.scrobbler.titleResId)
binding.imageViewLogo.setImageResource(scrobbling.scrobbler.iconResId)
binding.imageViewCover.newImageRequest(viewLifecycleOwner, scrobbling.coverUrl)?.apply {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
defaultPlaceholders(binding.imageViewCover.context)
enqueueWith(coil)
}
}

View File

@@ -19,6 +19,7 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
@@ -88,9 +89,7 @@ fun downloadItemAD(
binding.textViewTitle.text = item.manga?.title ?: getString(R.string.unknown)
if ((CoilUtils.result(binding.imageViewCover) as? SuccessResult)?.memoryCacheKey != item.coverCacheKey) {
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga?.coverUrl)?.apply {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
defaultPlaceholders(context)
allowRgb565(true)
transformations(TrimTransformation())
memoryCacheKey(item.coverCacheKey)

View File

@@ -111,7 +111,11 @@ class ExploreFragment :
}
override fun onListHeaderClick(item: ListHeader, view: View) {
startActivity(Intent(view.context, SourcesCatalogActivity::class.java))
if (item.payload == R.id.nav_suggestions) {
startActivity(SuggestionsActivity.newIntent(view.context))
} else {
startActivity(Intent(view.context, SourcesCatalogActivity::class.java))
}
}
override fun onPrimaryButtonClick(tipView: TipView) {

View File

@@ -25,10 +25,12 @@ import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.explore.ui.model.ExploreButtons
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
import org.koitharu.kotatsu.explore.ui.model.RecommendationsItem
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.model.EmptyHint
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -116,20 +118,20 @@ class ExploreViewModel @Inject constructor(
sourcesRepository.observeNewSources(),
) { content, suggestions, grid, randomLoading, newSources ->
buildList(content, suggestions, grid, randomLoading, newSources)
}
}.withErrorHandling()
private fun buildList(
sources: List<MangaSource>,
recommendation: Manga?,
recommendation: List<Manga>,
isGrid: Boolean,
randomLoading: Boolean,
newSources: Set<MangaSource>,
): List<ListModel> {
val result = ArrayList<ListModel>(sources.size + 3)
result += ExploreButtons(randomLoading)
if (recommendation != null) {
result += ListHeader(R.string.suggestions)
result += RecommendationsItem(recommendation)
if (recommendation.isNotEmpty()) {
result += ListHeader(R.string.suggestions, R.string.more, R.id.nav_suggestions)
result += RecommendationsItem(recommendation.toRecommendationList())
}
if (sources.isNotEmpty()) {
result += ListHeader(
@@ -157,16 +159,29 @@ class ExploreViewModel @Inject constructor(
private fun getSuggestionFlow() = isSuggestionsEnabled.mapLatest { isEnabled ->
if (isEnabled) {
runCatchingCancellable {
suggestionRepository.getRandom()
}.getOrNull()
suggestionRepository.getRandomList(SUGGESTIONS_COUNT)
}.getOrDefault(emptyList())
} else {
null
emptyList()
}
}
private fun List<Manga>.toRecommendationList() = map { manga ->
MangaListModel(
id = manga.id,
title = manga.title,
subtitle = manga.tags.joinToString { it.title },
coverUrl = manga.coverUrl,
manga = manga,
counter = 0,
progress = PROGRESS_NONE,
)
}
companion object {
private const val TIP_SUGGESTIONS = "suggestions"
private const val SUGGESTIONS_COUNT = 8
const val TIP_NEW_SOURCES = "new_sources"
}
}

View File

@@ -27,7 +27,7 @@ class ExploreAdapter(
addDelegate(ListItemType.EXPLORE_BUTTONS, exploreButtonsAD(listener))
addDelegate(
ListItemType.EXPLORE_SUGGESTION,
exploreRecommendationItemAD(coil, listener, mangaClickListener, lifecycleOwner),
exploreRecommendationItemAD(coil, mangaClickListener, lifecycleOwner),
)
addDelegate(ListItemType.HEADER, listHeaderAD(listener))
addDelegate(ListItemType.EXPLORE_SOURCE_LIST, exploreSourceListItemAD(coil, clickListener, lifecycleOwner))

View File

@@ -8,12 +8,15 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getSummary
import org.koitharu.kotatsu.core.model.getTitle
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.image.FaviconDrawable
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.setProgressIcon
import org.koitharu.kotatsu.core.util.ext.source
@@ -22,10 +25,13 @@ import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding
import org.koitharu.kotatsu.databinding.ItemExploreSourceGridBinding
import org.koitharu.kotatsu.databinding.ItemExploreSourceListBinding
import org.koitharu.kotatsu.databinding.ItemRecommendationBinding
import org.koitharu.kotatsu.databinding.ItemRecommendationMangaBinding
import org.koitharu.kotatsu.explore.ui.model.ExploreButtons
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
import org.koitharu.kotatsu.explore.ui.model.RecommendationsItem
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.parsers.model.Manga
fun exploreButtonsAD(
@@ -51,25 +57,40 @@ fun exploreButtonsAD(
fun exploreRecommendationItemAD(
coil: ImageLoader,
clickListener: View.OnClickListener,
itemClickListener: OnListItemClickListener<Manga>,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<RecommendationsItem, ListModel, ItemRecommendationBinding>(
{ layoutInflater, parent -> ItemRecommendationBinding.inflate(layoutInflater, parent, false) },
) {
binding.buttonMore.setOnClickListener(clickListener)
val adapter = BaseListAdapter<MangaListModel>()
.addDelegate(ListItemType.MANGA_LIST, recommendationMangaItemAD(coil, itemClickListener, lifecycleOwner))
binding.pager.adapter = adapter
binding.pager.recyclerView?.isNestedScrollingEnabled = false
binding.dots.bindToViewPager(binding.pager)
bind {
adapter.items = item.manga
}
}
fun recommendationMangaItemAD(
coil: ImageLoader,
itemClickListener: OnListItemClickListener<Manga>,
lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<MangaListModel, MangaListModel, ItemRecommendationMangaBinding>(
{ layoutInflater, parent -> ItemRecommendationMangaBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener { v ->
itemClickListener.onItemClick(item.manga, v)
}
bind {
binding.textViewTitle.text = item.manga.title
binding.textViewSubtitle.textAndVisible = item.summary
binding.textViewSubtitle.textAndVisible = item.subtitle
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder)
defaultPlaceholders(context)
allowRgb565(true)
transformations(TrimTransformation())
source(item.manga.source)
@@ -78,6 +99,7 @@ fun exploreRecommendationItemAD(
}
}
fun exploreSourceListItemAD(
coil: ImageLoader,
listener: OnListItemClickListener<MangaSourceItem>,

View File

@@ -1,12 +1,11 @@
package org.koitharu.kotatsu.explore.ui.model
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.list.ui.model.MangaListModel
data class RecommendationsItem(
val manga: Manga
val manga: List<MangaListModel>
) : ListModel {
val summary: String = manga.tags.joinToString { it.title }
override fun areItemsTheSame(other: ListModel): Boolean {
return other is RecommendationsItem

View File

@@ -210,6 +210,7 @@ abstract class FavouritesDao {
ListSortOrder.UNREAD -> "IFNULL((SELECT percent FROM history WHERE history.manga_id = manga.manga_id), 0) ASC"
ListSortOrder.LAST_READ -> "IFNULL((SELECT updated_at FROM history WHERE history.manga_id = manga.manga_id), 0) DESC"
ListSortOrder.LONG_AGO_READ -> "IFNULL((SELECT updated_at FROM history WHERE history.manga_id = manga.manga_id), 0) ASC"
ListSortOrder.UPDATED -> "IFNULL((SELECT last_chapter_date FROM tracks WHERE tracks.manga_id = manga.manga_id), 0) DESC"
else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
}

Some files were not shown because too many files have changed in this diff Show More