Compare commits

..

90 Commits

Author SHA1 Message Date
Koitharu
5d1a2fcf77 Statistics filters 2024-03-04 16:31:39 +02:00
Koitharu
876675445d Stats chart for single manga 2024-03-04 14:42:31 +02:00
Koitharu
f7a70680bd Timeline stats per manga 2024-03-01 15:00:38 +02:00
Koitharu
8e82db441c Empty stats state 2024-03-01 10:34:31 +02:00
Koitharu
f2626c668d Switch and click preference 2024-02-29 16:15:44 +02:00
Koitharu
4694215ccc Statistics periods 2024-02-29 15:28:57 +02:00
Koitharu
096f5b15dc Clearing stats 2024-02-29 14:27:52 +02:00
Koitharu
101d357eff Stats activity 2024-02-29 14:01:31 +02:00
Koitharu
11cd5609bb Use stats for reading time estimation 2024-02-29 12:12:09 +02:00
Koitharu
fda59996aa Improve stats ui 2024-02-29 12:01:09 +02:00
Koitharu
20461112d2 Merge branch 'devel' into feature/stats 2024-02-29 11:20:31 +02:00
Koitharu
f98bb87d6e Use numeric keyboard if app password is numeric 2024-02-29 11:20:10 +02:00
Koitharu
c451952a1e Merge branch 'devel' into feature/stats 2024-02-29 10:00:49 +02:00
Koitharu
f8cbc9692f Fix local manga directories chapters 2024-02-28 16:12:49 +02:00
Koitharu
9f3113363b Merge remote-tracking branch 'weblate/devel' into devel 2024-02-28 14:40:02 +02:00
Koitharu
dba36838d4 Download format preference 2024-02-28 14:28:59 +02:00
Koitharu
f6de1b02d7 Fix download item ui 2024-02-28 14:06:08 +02:00
abc0922001
d6b8e2fd9e Translated using Weblate (Chinese (Traditional))
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: abc0922001 <abc0922001@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hant/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
a
5227240478 Translated using Weblate (Portuguese)
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: a <cooki3yt2004@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Infy's Tagalog Translations
8f65ea6535 Translated using Weblate (Filipino)
Currently translated at 99.8% (596 of 597 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-02-28 12:59:46 +01:00
Kyoya
7d7a6eadd2 Translated using Weblate (Turkish)
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: Kyoya <thelol9181@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Scrambled777
40f1ad3181 Translated using Weblate (Hindi)
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Anon
a28c9447d7 Translated using Weblate (Serbian)
Currently translated at 100.0% (597 of 597 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/sr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-28 12:59:46 +01:00
gallegonovato
a84cf97982 Translated using Weblate (Spanish)
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Lokmane Abdelhakim Djilani
3a8eb58fd1 Translated using Weblate (Arabic)
Currently translated at 58.1% (347 of 597 strings)

Co-authored-by: Lokmane Abdelhakim Djilani <lokdabdo@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
gekka
5d75e9af4a Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (597 of 597 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (597 of 597 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Oğuz Ersen
d4684e7462 Translated using Weblate (Turkish)
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Çınar
c0a2f0b533 Translated using Weblate (Turkish)
Currently translated at 100.0% (597 of 597 strings)

Co-authored-by: Çınar <cinardogan110@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-02-28 12:59:46 +01:00
Макар Разин
40867dd2b6 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (597 of 597 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (597 of 597 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (597 of 597 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-02-28 12:59:46 +01:00
Koitharu
c3294e6459 Fix double pages mode enabling 2024-02-28 13:58:23 +02:00
Koitharu
5139feb51a Fix pages saving 2024-02-28 13:55:02 +02:00
Koitharu
6b1240fccb Fix crashes 2024-02-24 14:26:31 +02:00
Koitharu
e00a5b7505 Fix open Kitsu auth #773 2024-02-24 13:50:48 +02:00
Koitharu
2c07d2c8e1 Increase Kitsu password max length #774 2024-02-24 13:17:03 +02:00
Koitharu
45c3c05f01 Fix updating history in incognito mode #783 2024-02-24 13:13:35 +02:00
Koitharu
e97a745713 Fix filter ui issue #779 2024-02-24 12:50:59 +02:00
Koitharu
2dc4de0a3c Update dependencies 2024-02-24 12:25:44 +02:00
Koitharu
3cf2c58058 Local manga info dialog 2024-02-24 12:16:26 +02:00
Scrambled777
1e19f32fc5 Translated using Weblate (Hindi)
Currently translated at 22.6% (135 of 596 strings)

Co-authored-by: Scrambled777 <weblate.scrambled777@simplelogin.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Naga
99e4359523 Translated using Weblate (English)
Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Naga <yz2000.pro@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Alex Georgiou
04868488cc Translated using Weblate (English)
Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Alex Georgiou <alexandrosgeorgiou35@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Naga
2b3b406b84 Translated using Weblate (French)
Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (French)

Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (French)

Currently translated at 99.4% (593 of 596 strings)

Co-authored-by: Naga <yz2000.pro@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-24 12:14:04 +02:00
Infy's Tagalog Translations
7ab3c75232 Translated using Weblate (Filipino)
Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Filipino)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Infy's Tagalog Translations <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fil/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-24 12:14:04 +02:00
Макар Разин
61f7755465 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (596 of 596 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-02-24 12:14:04 +02:00
GpixeL
9389015ab9 Translated using Weblate (Indonesian)
Currently translated at 97.6% (582 of 596 strings)

Co-authored-by: GpixeL <gamesfire313@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Сергій
bc56a94aa6 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Сергій <sergiy.goncharuk.1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Madaraki
7cfcaec6dd Translated using Weblate (Russian)
Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Madaraki <115705267+Madaraki-chan@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
LaFouine-38
39c7ae31cd Translated using Weblate (French)
Currently translated at 92.6% (552 of 596 strings)

Co-authored-by: LaFouine-38 <thomasjb0208@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Sergio Varela
9349eccc0c Translated using Weblate (Spanish)
Currently translated at 100.0% (596 of 596 strings)

Co-authored-by: Sergio Varela <sergitroll9@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
gekka
8204934359 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.8% (595 of 596 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.4% (592 of 595 strings)

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

Translated using Weblate (Turkish)

Currently translated at 100.0% (596 of 596 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (595 of 595 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-02-24 12:14:04 +02:00
Koitharu
35a2ac4b04 Simple reading stats display 2024-02-21 09:49:47 +02:00
Koitharu
b4d52f1367 Fix track worker notification behavior 2024-02-20 12:46:12 +02:00
Zakhar Timoshenko
325a8be484 Fix wrong sources count if NSFW sources are disabled 2024-02-18 23:56:19 +03:00
Koitharu
f39ccb6223 Stats settings 2024-02-18 13:38:46 +02:00
Koitharu
6cb6c891dd Collecting reading stats 2024-02-18 13:11:41 +02:00
Koitharu
8cc04b0f7a Add Remove from history action on details activity 2024-02-18 10:07:42 +02:00
Koitharu
258dbf3dc3 Fix reader info bar text size 2024-02-18 09:26:57 +02:00
Koitharu
e7af4e8450 Fix backup restore dialog layout #770 2024-02-18 09:20:34 +02:00
Koitharu
0c25c61858 Fix filtering pages by branches 2024-02-18 09:14:49 +02:00
Koitharu
abc3e45907 Fix reader state on changed 2024-02-18 09:08:22 +02:00
Koitharu
bd98d8eded Fix onboarding sources selection 2024-02-18 08:44:13 +02:00
Koitharu
2e81f41073 Update dependencies 2024-02-18 08:44:13 +02:00
Oğuz Ersen
5cccebc416 Translated using Weblate (Turkish)
Currently translated at 100.0% (591 of 591 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-02-18 08:43:59 +02:00
Макар Разин
c668ffd555 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (591 of 591 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-02-18 08:43:59 +02:00
gekka
a0f77b715f Translated using Weblate (Chinese (Simplified))
Currently translated at 99.6% (589 of 591 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 99.8% (590 of 591 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-02-18 08:43:59 +02:00
Anonymous
2831843a25 Translated using Weblate (Chinese (Simplified))
Currently translated at 99.8% (590 of 591 strings)

Co-authored-by: Anonymous <noreply@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-02-18 08:43:59 +02:00
Koitharu
86c1aa11b0 Fix crash 2024-02-17 12:45:02 +02:00
Koitharu
d71514ec7a Configurable dir for silent pages saving 2024-02-17 12:31:12 +02:00
Koitharu
92ed320f57 Add compact navbar option 2024-02-17 10:41:55 +02:00
Koitharu
2de1fe8b77 Fix icon tint 2024-02-16 12:29:52 +02:00
vianh
cebc3cd9e8 Adjust search screen ui 2024-02-16 10:40:23 +02:00
vianh
6c0e2e2b90 Fix mirror domain switching 2024-02-16 10:40:23 +02:00
vianh
b4bd923ce8 Improve webtoon zoom fling 2024-02-16 10:39:17 +02:00
Koitharu
813561fd3b Merge remote-tracking branch 'weblate/devel' into devel 2024-02-16 10:37:43 +02:00
Jordan \"Tes\" Michel
4107336132 Translated using Weblate (French)
Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (French)

Currently translated at 93.4% (552 of 591 strings)

Co-authored-by: Jordan \"Tes\" Michel <jordan.michel13@yahoo.fr>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-16 10:35:52 +02:00
Anon
30d9d87c17 Translated using Weblate (Serbian)
Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Serbian)

Currently translated at 99.6% (589 of 591 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-02-16 10:35:52 +02:00
gekka
c4b5be657d Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (591 of 591 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-02-16 10:35:51 +02:00
Koitharu
8a763b2b9f Bring back adaptive reader control preference 2024-02-16 10:35:27 +02:00
Koitharu
c783378022 Update app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt 2024-02-15 10:26:22 +02:00
Isira Seneviratne
c4355f16e8 Improve serializable extra extension 2024-02-15 10:26:22 +02:00
Isira Seneviratne
522dfc2418 Use SoftwareKeyboardControllerCompat 2024-02-15 10:22:28 +02:00
Jordan \"Tes\" Michel
a7019b9096 Translated using Weblate (French)
Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (French)

Currently translated at 93.4% (552 of 591 strings)

Co-authored-by: Jordan \"Tes\" Michel <jordan.michel13@yahoo.fr>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-14 10:33:12 +01:00
Davi Silveira
867e3f10ca Translated using Weblate (Portuguese)
Currently translated at 100.0% (9 of 9 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (591 of 591 strings)

Co-authored-by: Davi Silveira <davilego10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-14 10:33:12 +01:00
Aiman Sara
fb2cf04d75 Translated using Weblate (Malay)
Currently translated at 53.9% (319 of 591 strings)

Translated using Weblate (Malay)

Currently translated at 100.0% (9 of 9 strings)

Co-authored-by: Aiman Sara <aimansara21@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ms/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ms/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-02-14 10:33:12 +01:00
Anon
3ed44ba0d6 Translated using Weblate (Serbian)
Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Serbian)

Currently translated at 99.6% (589 of 591 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-02-14 10:33:12 +01:00
gekka
b78104a0f1 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (591 of 591 strings)

Co-authored-by: gekka <1778962971@qq.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-02-14 10:33:12 +01:00
Madaraki
e4ee93f77c Translated using Weblate (Ukrainian)
Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (591 of 591 strings)

Co-authored-by: Madaraki <115705267+Madaraki-chan@users.noreply.github.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-02-14 10:33:11 +01:00
Oğuz Ersen
c6e8da5f23 Translated using Weblate (Turkish)
Currently translated at 100.0% (591 of 591 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2024-02-14 10:33:11 +01:00
gallegonovato
376de7cce3 Translated using Weblate (Spanish)
Currently translated at 100.0% (591 of 591 strings)

Translated using Weblate (Spanish)

Currently translated at 100.0% (589 of 589 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-02-14 10:33:11 +01:00
162 changed files with 3392 additions and 1097 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 34
versionCode = 623
versionName = '6.7.1'
versionCode = 626
versionName = '6.7.4'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@@ -82,13 +82,13 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:09e1c14f37') {
implementation('com.github.KotatsuApp:kotatsu-parsers:103f578c61') {
exclude group: 'org.json', module: 'json'
}
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
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'
@@ -126,13 +126,13 @@ dependencies {
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'com.google.dagger:hilt-android:2.50'
kapt 'com.google.dagger:hilt-compiler:2.50'
implementation 'androidx.hilt:hilt-work:1.1.0'
kapt 'androidx.hilt:hilt-compiler:1.1.0'
implementation 'com.google.dagger:hilt-android:2.51'
kapt 'com.google.dagger:hilt-compiler:2.51'
implementation 'androidx.hilt:hilt-work:1.2.0'
kapt 'androidx.hilt:hilt-compiler:1.2.0'
implementation 'io.coil-kt:coil-base:2.5.0'
implementation 'io.coil-kt:coil-svg:2.5.0'
implementation 'io.coil-kt:coil-base:2.6.0'
implementation 'io.coil-kt:coil-svg:2.6.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2'
@@ -148,18 +148,18 @@ dependencies {
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20240205'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0'
androidTestImplementation 'androidx.room:room-testing:2.6.1'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.50'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.50'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.51'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.51'
}

View File

@@ -57,6 +57,7 @@ class AppShortcutManagerTest {
page = 4,
scroll = 2,
percent = 0.3f,
force = false,
)
awaitUpdate()

View File

@@ -7,7 +7,9 @@ import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -61,6 +63,7 @@ class AppBackupAgentTest {
page = 3,
scroll = 40,
percent = 0.2f,
force = false,
)
val history = checkNotNull(historyRepository.getOne(SampleData.manga))

View File

@@ -239,6 +239,9 @@
<data android:scheme="kotatsu+kitsu" />
</intent-filter>
</activity>
<activity
android:name="org.koitharu.kotatsu.stats.ui.StatsActivity"
android:label="@string/reading_stats" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"

View File

@@ -14,7 +14,6 @@ import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.network.UserAgents
import com.google.android.material.R as materialR
@@ -26,7 +25,7 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
return
}
supportActionBar?.run {

View File

@@ -27,7 +27,6 @@ import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.network.UserAgents
import javax.inject.Inject
@@ -45,13 +44,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!catchingWebViewUnavailability {
setContentView(
ActivityBrowserBinding.inflate(
layoutInflater,
),
)
}) {
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
return
}
supportActionBar?.run {

View File

@@ -54,6 +54,7 @@ class JsonDeserializer(private val json: JSONObject) {
page = json.getInt("page"),
scroll = json.getDouble("scroll").toFloat(),
percent = json.getFloatOrDefault("percent", -1f),
chaptersCount = json.getIntOrDefault("chapters", -1),
deletedAt = 0L,
)

View File

@@ -41,6 +41,7 @@ class JsonSerializer private constructor(private val json: JSONObject) {
put("page", e.page)
put("scroll", e.scroll)
put("percent", e.percent)
put("chapters", e.chaptersCount)
},
)

View File

@@ -30,6 +30,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration14To15
import org.koitharu.kotatsu.core.db.migrations.Migration15To16
import org.koitharu.kotatsu.core.db.migrations.Migration16To17
import org.koitharu.kotatsu.core.db.migrations.Migration17To18
import org.koitharu.kotatsu.core.db.migrations.Migration18To19
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
@@ -48,20 +49,22 @@ import org.koitharu.kotatsu.history.data.HistoryDao
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
import org.koitharu.kotatsu.stats.data.StatsDao
import org.koitharu.kotatsu.stats.data.StatsEntity
import org.koitharu.kotatsu.suggestions.data.SuggestionDao
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TracksDao
const val DATABASE_VERSION = 18
const val DATABASE_VERSION = 19
@Database(
entities = [
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
ScrobblingEntity::class, MangaSourceEntity::class,
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class,
],
version = DATABASE_VERSION,
)
@@ -90,6 +93,8 @@ abstract class MangaDatabase : RoomDatabase() {
abstract fun getScrobblingDao(): ScrobblingDao
abstract fun getSourcesDao(): MangaSourcesDao
abstract fun getStatsDao(): StatsDao
}
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
@@ -110,6 +115,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
Migration15To16(),
Migration16To17(context),
Migration17To18(),
Migration18To19(),
)
fun MangaDatabase(context: Context): MangaDatabase = Room

View File

@@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.db.migrations
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
class Migration18To19 : Migration(18, 19) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE history ADD COLUMN `chapters` INTEGER NOT NULL DEFAULT -1")
db.execSQL("CREATE TABLE IF NOT EXISTS `stats` (`manga_id` INTEGER NOT NULL, `started_at` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `pages` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `started_at`), FOREIGN KEY(`manga_id`) REFERENCES `history`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
}
}

View File

@@ -227,6 +227,5 @@ class RemoteMangaRepository(
}
}
private fun Result<*>.isValidResult() = exceptionOrNull() !is ParseException
&& (getOrNull() as? Collection<*>)?.isEmpty() != true
private fun Result<*>.isValidResult() = isSuccess && (getOrNull() as? Collection<*>)?.isEmpty() != true
}

View File

@@ -11,6 +11,7 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.collection.ArraySet
import androidx.core.content.edit
import androidx.core.os.LocaleListCompat
import androidx.documentfile.provider.DocumentFile
import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext
import org.json.JSONArray
@@ -26,6 +27,7 @@ import org.koitharu.kotatsu.explore.data.SourcesSortOrder
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.isNumeric
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
@@ -70,6 +72,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
}
}
val isNavLabelsVisible: Boolean
get() = prefs.getBoolean(KEY_NAV_LABELS, true)
var gridSize: Int
get() = prefs.getInt(KEY_GRID_SIZE, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
@@ -111,6 +116,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderZoomButtonsEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_ZOOM_BUTTONS, false)
val isReaderControlAlwaysLTR: Boolean
get() = prefs.getBoolean(KEY_READER_CONTROL_LTR, false)
val isReaderFullscreenEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_FULLSCREEN, true)
@@ -184,11 +192,13 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
var appPassword: String?
get() = prefs.getString(KEY_APP_PASSWORD, null)
set(value) = prefs.edit {
if (value != null) putString(KEY_APP_PASSWORD, value) else remove(
KEY_APP_PASSWORD,
)
if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD)
}
var isAppPasswordNumeric: Boolean
get() = prefs.getBoolean(KEY_APP_PASSWORD_NUMERIC, false)
set(value) = prefs.edit { putBoolean(KEY_APP_PASSWORD_NUMERIC, value) }
val isLoggingEnabled: Boolean
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
@@ -270,6 +280,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isDownloadsWiFiOnly: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
val preferredDownloadFormat: DownloadFormat
get() = prefs.getEnumValue(KEY_DOWNLOADS_FORMAT, DownloadFormat.AUTOMATIC)
var isSuggestionsEnabled: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) }
@@ -406,6 +419,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReadingTimeEstimationEnabled: Boolean
get() = prefs.getBoolean(KEY_READING_TIME, true)
val isPagesSavingAskEnabled: Boolean
get() = prefs.getBoolean(KEY_PAGES_SAVE_ASK, true)
val isStatsEnabled: Boolean
get() = prefs.getBoolean(KEY_STATS_ENABLED, false)
fun isTipEnabled(tip: String): Boolean {
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
}
@@ -418,6 +437,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
prefs.edit { putStringSet(KEY_TIPS_CLOSED, closedTips + tip) }
}
fun getPagesSaveDir(context: Context): DocumentFile? =
prefs.getString(KEY_PAGES_SAVE_DIR, null)?.toUriOrNull()?.let {
DocumentFile.fromTreeUri(context, it)?.takeIf { it.canWrite() }
}
fun setPagesSaveDir(uri: Uri?) {
prefs.edit { putString(KEY_PAGES_SAVE_DIR, uri?.toString()) }
}
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
prefs.registerOnSharedPreferenceChangeListener(listener)
}
@@ -488,6 +516,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_LOCAL_STORAGE = "local_storage"
const val KEY_READER_DOUBLE_PAGES = "reader_double_pages"
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
const val KEY_READER_FULLSCREEN = "reader_fullscreen"
const val KEY_READER_VOLUME_BUTTONS = "reader_volume_buttons"
const val KEY_TRACKER_ENABLED = "tracker_enabled"
@@ -505,6 +534,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_MODE = "reader_mode"
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
const val KEY_APP_PASSWORD = "app_password"
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
const val KEY_PROTECT_APP = "protect_app"
const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio"
const val KEY_APP_VERSION = "app_version"
@@ -532,6 +562,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_MAL = "mal"
const val KEY_KITSU = "kitsu"
const val KEY_DOWNLOADS_WIFI = "downloads_wifi"
const val KEY_DOWNLOADS_FORMAT = "downloads_format"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
const val KEY_DOH = "doh"
const val KEY_EXIT_CONFIRM = "exit_confirm"
@@ -573,6 +604,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_DISABLE_NSFW = "no_nsfw"
const val KEY_RELATED_MANGA = "related_manga"
const val KEY_NAV_MAIN = "nav_main"
const val KEY_NAV_LABELS = "nav_labels"
const val KEY_32BIT_COLOR = "enhanced_colors"
const val KEY_SOURCES_ORDER = "sources_sort_order"
const val KEY_SOURCES_CATALOG = "sources_catalog"
@@ -583,8 +615,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_IGNORE_DOZE = "ignore_dose"
const val KEY_DETAILS_TAB = "details_tab"
const val KEY_READING_TIME = "reading_time"
// About
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"
}

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.core.prefs
enum class DownloadFormat {
AUTOMATIC,
SINGLE_CBZ,
MULTIPLE_CBZ,
}

View File

@@ -8,6 +8,7 @@ import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.annotation.CallSuper
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
@@ -29,6 +30,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
import org.koitharu.kotatsu.core.ui.util.BaseActivityEntryPoint
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
@Suppress("LeakingThis")
abstract class BaseActivity<B : ViewBinding> :
@@ -164,6 +166,21 @@ abstract class BaseActivity<B : ViewBinding> :
intent?.putExtra(EXTRA_DATA, intent.data)
}
protected fun setContentViewWebViewSafe(viewBindingProducer: () -> B): Boolean {
return try {
setContentView(viewBindingProducer())
true
} catch (e: Exception) {
if (e.isWebViewUnavailable()) {
Toast.makeText(this, R.string.web_view_unavailable, Toast.LENGTH_LONG).show()
finishAfterTransition()
false
} else {
throw e
}
}
}
companion object {
const val EXTRA_DATA = "data"

View File

@@ -29,8 +29,9 @@ open class BaseListAdapter<T : ListModel> : AsyncListDifferDelegationAdapter<T>(
return this
}
fun addListListener(listListener: ListListener<T>) {
fun addListListener(listListener: ListListener<T>): BaseListAdapter<T> {
differ.addListListener(listListener)
return this
}
fun removeListListener(listListener: ListListener<T>) {

View File

@@ -68,6 +68,13 @@ abstract class BaseViewModel : ViewModel() {
errorEvent.call(error)
}
protected inline suspend fun <T> withLoading(block: () -> T): T = try {
loadingCounter.increment()
block()
} finally {
loadingCounter.decrement()
}
protected fun MutableStateFlow<Int>.increment() = update { it + 1 }
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }

View File

@@ -68,6 +68,14 @@ class RecyclerViewAlertDialog private constructor(
return this
}
fun setNeutralButton(
@StringRes textId: Int,
listener: DialogInterface.OnClickListener,
): Builder<T> {
delegate.setNeutralButton(textId, listener)
return this
}
fun setCancelable(isCancelable: Boolean): Builder<T> {
delegate.setCancelable(isCancelable)
return this

View File

@@ -12,11 +12,10 @@ import android.graphics.RectF
import android.graphics.drawable.Drawable
import androidx.annotation.StyleRes
import androidx.core.content.withStyledAttributes
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.withClip
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.R
import kotlin.math.absoluteValue
import org.koitharu.kotatsu.core.util.KotatsuColors
class FaviconDrawable(
context: Context,
@@ -44,7 +43,7 @@ class FaviconDrawable(
}
paint.textAlign = Paint.Align.CENTER
paint.isFakeBoldText = true
colorForeground = MaterialColors.harmonize(colorOfString(name), colorBackground)
colorForeground = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
}
override fun draw(canvas: Canvas) {
@@ -104,9 +103,4 @@ class FaviconDrawable(
paint.getTextBounds(text, 0, text.length, tempRect)
return testTextSize * width / tempRect.width()
}
private fun colorOfString(str: String): Int {
val hue = (str.hashCode() % 360).absoluteValue.toFloat()
return ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
}
}

View File

@@ -7,12 +7,16 @@ import org.koitharu.kotatsu.R
class ReversibleActionObserver(
private val snackbarHost: View,
private val snackbarAnchor: View? = null,
) : FlowCollector<ReversibleAction> {
override suspend fun emit(value: ReversibleAction) {
val handle = value.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
val snackbar = Snackbar.make(snackbarHost, value.stringResId, length)
if (snackbarAnchor?.isShown == true) {
snackbar.anchorView = snackbarAnchor
}
if (handle != null) {
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
}

View File

@@ -110,7 +110,7 @@ class ChipsView @JvmOverloads constructor(
chip.isChipIconVisible = false
chip.isCloseIconVisible = onChipCloseClickListener != null
chip.setOnCloseIconClickListener(chipOnCloseListener)
chip.setEnsureMinTouchTargetSize(false)
chip.setEnsureMinTouchTargetSize(false) // TODO remove
chip.setOnClickListener(chipOnClickListener)
addView(chip)
return chip

View File

@@ -1,397 +0,0 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.CornerPathEffect
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.Typeface
import android.os.Build
import android.os.Parcelable
import android.text.Layout
import android.text.StaticLayout
import android.text.TextDirectionHeuristic
import android.text.TextDirectionHeuristics
import android.text.TextPaint
import android.util.AttributeSet
import android.view.View
import androidx.annotation.RequiresApi
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.draw
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.resolveSp
class PieChart @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr), PieChartInterface {
private var marginTextFirst: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_1)
private var marginTextSecond: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_2)
private var marginTextThird: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_3)
private var marginSmallCircle: Float = context.resources.resolveDp(DEFAULT_MARGIN_SMALL_CIRCLE)
private val marginText: Float = marginTextFirst + marginTextSecond
private val circleRect = RectF()
private var circleStrokeWidth: Float = context.resources.resolveDp(6f)
private var circleRadius: Float = 0f
private var circlePadding: Float = context.resources.resolveDp(8f)
private var circlePaintRoundSize: Boolean = true
private var circleSectionSpace: Float = 3f
private var circleCenterX: Float = 0f
private var circleCenterY: Float = 0f
private var numberTextPaint: TextPaint = TextPaint()
private var descriptionTextPain: TextPaint = TextPaint()
private var amountTextPaint: TextPaint = TextPaint()
private var textStartX: Float = 0f
private var textStartY: Float = 0f
private var textHeight: Int = 0
private var textCircleRadius: Float = context.resources.resolveDp(4f)
private var textAmountStr: String = ""
private var textAmountY: Float = 0f
private var textAmountXNumber: Float = 0f
private var textAmountXDescription: Float = 0f
private var textAmountYDescription: Float = 0f
private var totalAmount: Int = 0
private var pieChartColors: List<String> = listOf()
private var percentageCircleList: List<PieChartModel> = listOf()
private var textRowList: MutableList<StaticLayout> = mutableListOf()
private var dataList: List<Pair<Int, String>> = listOf()
private var animationSweepAngle: Int = 0
init {
var textAmountSize: Float = context.resources.resolveSp(22f)
var textNumberSize: Float = context.resources.resolveSp(20f)
var textDescriptionSize: Float = context.resources.resolveSp(14f)
var textAmountColor: Int = Color.WHITE
var textNumberColor: Int = Color.WHITE
var textDescriptionColor: Int = Color.GRAY
if (attrs != null) {
val typeArray = context.obtainStyledAttributes(attrs, R.styleable.PieChart)
val colorResId = typeArray.getResourceId(R.styleable.PieChart_pieChartColors, 0)
pieChartColors = typeArray.resources.getStringArray(colorResId).toList()
marginTextFirst = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextFirst, marginTextFirst)
marginTextSecond = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextSecond, marginTextSecond)
marginTextThird = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextThird, marginTextThird)
marginSmallCircle =
typeArray.getDimension(R.styleable.PieChart_pieChartMarginSmallCircle, marginSmallCircle)
circleStrokeWidth =
typeArray.getDimension(R.styleable.PieChart_pieChartCircleStrokeWidth, circleStrokeWidth)
circlePadding = typeArray.getDimension(R.styleable.PieChart_pieChartCirclePadding, circlePadding)
circlePaintRoundSize =
typeArray.getBoolean(R.styleable.PieChart_pieChartCirclePaintRoundSize, circlePaintRoundSize)
circleSectionSpace = typeArray.getFloat(R.styleable.PieChart_pieChartCircleSectionSpace, circleSectionSpace)
textCircleRadius = typeArray.getDimension(R.styleable.PieChart_pieChartTextCircleRadius, textCircleRadius)
textAmountSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextAmountSize, textAmountSize)
textNumberSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextNumberSize, textNumberSize)
textDescriptionSize =
typeArray.getDimension(R.styleable.PieChart_pieChartTextDescriptionSize, textDescriptionSize)
textAmountColor = typeArray.getColor(R.styleable.PieChart_pieChartTextAmountColor, textAmountColor)
textNumberColor = typeArray.getColor(R.styleable.PieChart_pieChartTextNumberColor, textNumberColor)
textDescriptionColor =
typeArray.getColor(R.styleable.PieChart_pieChartTextDescriptionColor, textDescriptionColor)
textAmountStr = typeArray.getString(R.styleable.PieChart_pieChartTextAmount) ?: ""
typeArray.recycle()
}
circlePadding += circleStrokeWidth
// Инициализация кистей View
initPaints(amountTextPaint, textAmountSize, textAmountColor)
initPaints(numberTextPaint, textNumberSize, textNumberColor)
initPaints(descriptionTextPain, textDescriptionSize, textDescriptionColor, true)
}
@RequiresApi(Build.VERSION_CODES.M)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
textRowList.clear()
val initSizeWidth = resolveDefaultSize(widthMeasureSpec, DEFAULT_VIEW_SIZE_WIDTH)
val textTextWidth = (initSizeWidth * TEXT_WIDTH_PERCENT)
val initSizeHeight = calculateViewHeight(heightMeasureSpec, textTextWidth.toInt())
textStartX = initSizeWidth - textTextWidth.toFloat()
textStartY = initSizeHeight.toFloat() / 2 - textHeight / 2
calculateCircleRadius(initSizeWidth, initSizeHeight)
setMeasuredDimension(initSizeWidth, initSizeHeight)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
drawCircle(canvas)
drawText(canvas)
}
override fun onRestoreInstanceState(state: Parcelable?) {
val pieChartState = state as? PieChartState
super.onRestoreInstanceState(pieChartState?.superState ?: state)
dataList = pieChartState?.dataList ?: listOf()
}
override fun onSaveInstanceState(): Parcelable {
val superState = super.onSaveInstanceState()
return PieChartState(superState, dataList)
}
override fun setDataChart(list: List<Pair<Int, String>>) {
dataList = list
calculatePercentageOfData()
}
override fun startAnimation() {
val animator = ValueAnimator.ofInt(0, 360).apply {
duration = context.getAnimationDuration(android.R.integer.config_longAnimTime)
interpolator = FastOutSlowInInterpolator()
addUpdateListener { valueAnimator ->
animationSweepAngle = valueAnimator.animatedValue as Int
invalidate()
}
}
animator.start()
}
private fun drawCircle(canvas: Canvas) {
for (percent in percentageCircleList) {
if (animationSweepAngle > percent.percentToStartAt + percent.percentOfCircle) {
canvas.drawArc(circleRect, percent.percentToStartAt, percent.percentOfCircle, false, percent.paint)
} else if (animationSweepAngle > percent.percentToStartAt) {
canvas.drawArc(
circleRect,
percent.percentToStartAt,
animationSweepAngle - percent.percentToStartAt,
false,
percent.paint,
)
}
}
}
private fun drawText(canvas: Canvas) {
var textBuffY = textStartY
textRowList.forEachIndexed { index, staticLayout ->
if (index % 2 == 0) {
staticLayout.draw(canvas, textStartX + marginSmallCircle + textCircleRadius, textBuffY)
canvas.drawCircle(
textStartX + marginSmallCircle / 2,
textBuffY + staticLayout.height / 2 + textCircleRadius / 2,
textCircleRadius,
Paint().apply { color = Color.parseColor(pieChartColors[(index / 2) % pieChartColors.size]) },
)
textBuffY += staticLayout.height + marginTextFirst
} else {
staticLayout.draw(canvas, textStartX, textBuffY)
textBuffY += staticLayout.height + marginTextSecond
}
}
canvas.drawText(totalAmount.toString(), textAmountXNumber, textAmountY, amountTextPaint)
canvas.drawText(textAmountStr, textAmountXDescription, textAmountYDescription, descriptionTextPain)
}
private fun initPaints(textPaint: TextPaint, textSize: Float, textColor: Int, isDescription: Boolean = false) {
textPaint.color = textColor
textPaint.textSize = textSize
textPaint.isAntiAlias = true
if (!isDescription) textPaint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
}
private fun resolveDefaultSize(spec: Int, defValue: Int): Int {
return when (MeasureSpec.getMode(spec)) {
MeasureSpec.UNSPECIFIED -> resources.resolveDp(defValue)
else -> MeasureSpec.getSize(spec)
}
}
@RequiresApi(Build.VERSION_CODES.M)
private fun calculateViewHeight(heightMeasureSpec: Int, textWidth: Int): Int {
val initSizeHeight = resolveDefaultSize(heightMeasureSpec, DEFAULT_VIEW_SIZE_HEIGHT)
textHeight = (dataList.size * marginText + getTextViewHeight(textWidth)).toInt()
val textHeightWithPadding = textHeight + paddingTop + paddingBottom
return if (textHeightWithPadding > initSizeHeight) textHeightWithPadding else initSizeHeight
}
private fun calculateCircleRadius(width: Int, height: Int) {
val circleViewWidth = (width * CIRCLE_WIDTH_PERCENT)
circleRadius = if (circleViewWidth > height) {
(height.toFloat() - circlePadding) / 2
} else {
circleViewWidth.toFloat() / 2
}
with(circleRect) {
left = circlePadding
top = height / 2 - circleRadius
right = circleRadius * 2 + circlePadding
bottom = height / 2 + circleRadius
}
circleCenterX = (circleRadius * 2 + circlePadding + circlePadding) / 2
circleCenterY = (height / 2 + circleRadius + (height / 2 - circleRadius)) / 2
textAmountY = circleCenterY
val sizeTextAmountNumber = getWidthOfAmountText(
totalAmount.toString(),
amountTextPaint,
)
textAmountXNumber = circleCenterX - sizeTextAmountNumber.width() / 2
textAmountXDescription = circleCenterX - getWidthOfAmountText(textAmountStr, descriptionTextPain).width() / 2
textAmountYDescription = circleCenterY + sizeTextAmountNumber.height() + marginTextThird
}
@RequiresApi(Build.VERSION_CODES.M)
private fun getTextViewHeight(maxWidth: Int): Int {
var textHeight = 0
dataList.forEach {
val textLayoutNumber = getMultilineText(
text = it.first.toString(),
textPaint = numberTextPaint,
width = maxWidth,
)
val textLayoutDescription = getMultilineText(
text = it.second,
textPaint = descriptionTextPain,
width = maxWidth,
)
textRowList.apply {
add(textLayoutNumber)
add(textLayoutDescription)
}
textHeight += textLayoutNumber.height + textLayoutDescription.height
}
return textHeight
}
private fun calculatePercentageOfData() {
totalAmount = dataList.fold(0) { res, value -> res + value.first }
var startAt = circleSectionSpace
percentageCircleList = dataList.mapIndexed { index, pair ->
var percent = pair.first * 100 / totalAmount.toFloat() - circleSectionSpace
percent = if (percent < 0f) 0f else percent
val resultModel = PieChartModel(
percentOfCircle = percent,
percentToStartAt = startAt,
colorOfLine = Color.parseColor(pieChartColors[index % pieChartColors.size]),
stroke = circleStrokeWidth,
paintRound = circlePaintRoundSize,
)
if (percent != 0f) startAt += percent + circleSectionSpace
resultModel
}
}
private fun getWidthOfAmountText(text: String, textPaint: TextPaint): Rect {
val bounds = Rect()
textPaint.getTextBounds(text, 0, text.length, bounds)
return bounds
}
@RequiresApi(Build.VERSION_CODES.M)
private fun getMultilineText(
text: CharSequence,
textPaint: TextPaint,
width: Int,
start: Int = 0,
end: Int = text.length,
alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL,
textDir: TextDirectionHeuristic = TextDirectionHeuristics.LTR,
spacingMult: Float = 1f,
spacingAdd: Float = 0f
): StaticLayout {
return StaticLayout.Builder
.obtain(text, start, end, textPaint, width)
.setAlignment(alignment)
.setTextDirection(textDir)
.setLineSpacing(spacingAdd, spacingMult)
.build()
}
companion object {
private const val DEFAULT_MARGIN_TEXT_1 = 2f
private const val DEFAULT_MARGIN_TEXT_2 = 10f
private const val DEFAULT_MARGIN_TEXT_3 = 2f
private const val DEFAULT_MARGIN_SMALL_CIRCLE = 12f
private const val TEXT_WIDTH_PERCENT = 0.40
private const val CIRCLE_WIDTH_PERCENT = 0.50
const val DEFAULT_VIEW_SIZE_HEIGHT = 150
const val DEFAULT_VIEW_SIZE_WIDTH = 250
}
}
interface PieChartInterface {
fun setDataChart(list: List<Pair<Int, String>>)
fun startAnimation()
}
data class PieChartModel(
var percentOfCircle: Float = 0f,
var percentToStartAt: Float = 0f,
var colorOfLine: Int = 0,
var stroke: Float = 0f,
var paint: Paint = Paint(),
var paintRound: Boolean = true
) {
init {
if (percentOfCircle < 0 || percentOfCircle > 100) {
percentOfCircle = 100f
}
percentOfCircle = 360 * percentOfCircle / 100
if (percentToStartAt < 0 || percentToStartAt > 100) {
percentToStartAt = 0f
}
percentToStartAt = 360 * percentToStartAt / 100
if (colorOfLine == 0) {
colorOfLine = Color.parseColor("#000000")
}
paint = Paint()
paint.color = colorOfLine
paint.isAntiAlias = true
paint.style = Paint.Style.STROKE
paint.strokeWidth = stroke
paint.isDither = true
if (paintRound) {
paint.strokeJoin = Paint.Join.ROUND
paint.strokeCap = Paint.Cap.ROUND
paint.pathEffect = CornerPathEffect(8f)
}
}
}
class PieChartState(
superSavedState: Parcelable?,
val dataList: List<Pair<Int, String>>
) : View.BaseSavedState(superSavedState), Parcelable

View File

@@ -0,0 +1,55 @@
package org.koitharu.kotatsu.core.util
import android.content.Context
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.core.graphics.ColorUtils
import com.google.android.material.R
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.parsers.model.Manga
import kotlin.math.absoluteValue
object KotatsuColors {
@ColorInt
fun segmentColor(context: Context, @AttrRes resId: Int): Int {
val colorHex = String.format("%06x", context.getThemeColor(resId))
val hue = getHue(colorHex)
val color = ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh)
return MaterialColors.harmonize(color, backgroundColor)
}
@ColorInt
fun random(seed: Any): Int {
val hue = (seed.hashCode() % 360).absoluteValue.toFloat()
return ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
}
@ColorInt
fun ofManga(context: Context, manga: Manga?): Int {
val color = if (manga != null) {
val hue = (manga.id.absoluteValue % 360).toFloat()
ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
} else {
context.getThemeColor(R.attr.colorSurface)
}
val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh)
return MaterialColors.harmonize(color, backgroundColor)
}
private fun getHue(hex: String): Float {
val r = (hex.substring(0, 2).toInt(16)).toFloat()
val g = (hex.substring(2, 4).toInt(16)).toFloat()
val b = (hex.substring(4, 6).toInt(16)).toFloat()
var hue = 0F
if ((r >= g) && (g >= b)) {
hue = 60 * (g - b) / (r - b)
} else if ((g > r) && (r >= b)) {
hue = 60 * (2 - (r - b) / (g - b))
}
return hue
}
}

View File

@@ -27,7 +27,6 @@ import android.provider.Settings
import android.view.View
import android.view.ViewPropertyAnimator
import android.view.Window
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.IntegerRes
import androidx.annotation.WorkerThread
@@ -216,21 +215,6 @@ fun Context.findActivity(): Activity? = when (this) {
else -> null
}
inline fun Activity.catchingWebViewUnavailability(block: () -> Unit): Boolean {
return try {
block()
true
} catch (e: Exception) {
if (e.isWebViewUnavailable()) {
Toast.makeText(this, R.string.web_view_unavailable, Toast.LENGTH_LONG).show()
finishAfterTransition()
false
} else {
throw e
}
}
}
fun Context.checkNotificationPermission(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
} else {

View File

@@ -24,11 +24,15 @@ inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String)
}
inline fun <reified T : Serializable> Intent.getSerializableExtraCompat(key: String): T? {
return getSerializableExtra(key) as T?
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
getSerializableExtra(key, T::class.java)
} else {
getSerializableExtra(key) as T?
}
}
inline fun <reified T : Serializable> Bundle.getSerializableCompat(key: String): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
getSerializable(key, T::class.java)
} else {
getSerializable(key) as T?

View File

@@ -19,6 +19,7 @@ import java.nio.file.attribute.BasicFileAttributes
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.PathWalkOption
import kotlin.io.path.readAttributes
import kotlin.io.path.walk
@@ -72,7 +73,7 @@ fun ContentResolver.resolveName(uri: Uri): String? {
}
suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) {
walkCompat().sumOf { it.length() }
walkCompat(includeDirectories = false).sumOf { it.length() }
}
fun File.children() = FileSequence(this)
@@ -87,10 +88,16 @@ val File.creationTime
}
@OptIn(ExperimentalPathApi::class)
fun File.walkCompat() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
fun File.walkCompat(includeDirectories: Boolean): Sequence<File> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Use lazy loading on Android 8.0 and later
toPath().walk().map { it.toFile() }
val walk = if (includeDirectories) {
toPath().walk(PathWalkOption.INCLUDE_DIRECTORIES)
} else {
toPath().walk()
}
walk.map { it.toFile() }
} else {
// Directories are excluded by default in Path.walk(), so do it here as well
walk().filter { it.isFile }
val walk = walk()
if (includeDirectories) walk else walk.filter { it.isFile }
}

View File

@@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core.util.ext
import android.content.ActivityNotFoundException
import android.content.res.Resources
import android.util.AndroidRuntimeException
import androidx.annotation.DrawableRes
import androidx.collection.arraySetOf
import coil.network.HttpException
@@ -115,8 +114,8 @@ private val reportableExceptions = arraySetOf<Class<*>>(
)
fun Throwable.isWebViewUnavailable(): Boolean {
return (this is AndroidRuntimeException && message?.contains("WebView") == true) ||
cause?.isWebViewUnavailable() == true
val trace = stackTraceToString()
return trace.contains("android.webkit.WebView.<init>")
}
@Suppress("FunctionName")

View File

@@ -1,15 +1,14 @@
package org.koitharu.kotatsu.core.util.ext
import android.app.Activity
import android.graphics.Rect
import android.os.Build
import android.view.View
import android.view.View.MeasureSpec
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.Checkable
import androidx.appcompat.widget.ActionMenuView
import androidx.appcompat.widget.Toolbar
import androidx.core.view.SoftwareKeyboardControllerCompat
import androidx.core.view.children
import androidx.core.view.descendants
import androidx.core.view.isVisible
@@ -24,13 +23,11 @@ import com.google.android.material.tabs.TabLayout
import kotlin.math.roundToInt
fun View.hideKeyboard() {
val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(this.windowToken, 0)
SoftwareKeyboardControllerCompat(this).hide()
}
fun View.showKeyboard() {
val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(this, 0)
SoftwareKeyboardControllerCompat(this).show()
}
fun View.hasGlobalPoint(x: Int, y: Int): Boolean {

View File

@@ -10,6 +10,7 @@ data class ReadingTime(
) {
fun format(resources: Resources): String = when {
hours == 0 && minutes == 0 -> resources.getString(R.string.less_than_minute)
hours == 0 -> resources.getQuantityString(R.plurals.minutes, minutes, minutes)
minutes == 0 -> resources.getQuantityString(R.plurals.hours, hours, hours)
else -> resources.getString(

View File

@@ -5,25 +5,27 @@ import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.data.ReadingTime
import org.koitharu.kotatsu.stats.data.StatsRepository
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlin.math.roundToInt
class ReadingTimeUseCase @Inject constructor(
private val settings: AppSettings,
private val statsRepository: StatsRepository,
) {
fun invoke(manga: MangaDetails?, branch: String?, history: MangaHistory?): ReadingTime? {
suspend fun invoke(manga: MangaDetails?, branch: String?, history: MangaHistory?): ReadingTime? {
if (!settings.isReadingTimeEstimationEnabled) {
return null
}
// FIXME MAXIMUM HARDCODE!!! To do calculation with user's page read speed and his favourites/history mangas average pages in chapter
val chapters = manga?.chapters?.get(branch)
if (chapters.isNullOrEmpty()) {
return null
}
val isOnHistoryBranch = history != null && chapters.findById(history.chapterId) != null
// Impossible task, I guess. Good luck on this.
var averageTimeSec: Int = 20 * 10 * chapters.size // 20 pages, 10 seconds per page
var averageTimeSec: Int = 20 /* pages */ * getSecondsPerPage(manga.id) * chapters.size
if (isOnHistoryBranch) {
averageTimeSec = (averageTimeSec * (1f - checkNotNull(history).percent)).roundToInt()
}
@@ -36,4 +38,16 @@ class ReadingTimeUseCase @Inject constructor(
isContinue = isOnHistoryBranch,
)
}
private suspend fun getSecondsPerPage(mangaId: Long): Int {
var time = if (settings.isStatsEnabled) {
TimeUnit.MILLISECONDS.toSeconds(statsRepository.getTimePerPage(mangaId)).toInt()
} else {
0
}
if (time == 0) {
time = 10 // default
}
return time
}
}

View File

@@ -138,7 +138,10 @@ class DetailsActivity :
},
),
)
viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.containerDetails))
viewModel.onActionDone.observeEvent(
this,
ReversibleActionObserver(viewBinding.containerDetails, viewBinding.layoutBottom),
)
viewModel.onShowTip.observeEvent(this) { showTip() }
viewModel.historyInfo.observe(this, ::onHistoryChanged)
viewModel.selectedBranch.observe(this) {
@@ -150,6 +153,7 @@ class DetailsActivity :
viewModel.isChaptersEmpty.observe(this, chaptersMenuInvalidator)
val menuInvalidator = MenuInvalidator(this)
viewModel.favouriteCategories.observe(this, menuInvalidator)
viewModel.isStatsEnabled.observe(this, menuInvalidator)
viewModel.remoteManga.observe(this, menuInvalidator)
viewModel.branches.observe(this) {
viewBinding.buttonDropdown.isVisible = it.size > 1
@@ -187,6 +191,9 @@ class DetailsActivity :
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 {
!isIncognitoMode && history != null
}
menu.setOnMenuItemClickListener(this)
menu.setForceShowIcon(true)
menu.show()
@@ -203,6 +210,11 @@ class DetailsActivity :
true
}
R.id.action_forget -> {
viewModel.removeFromHistory()
true
}
R.id.action_pages_thumbs -> {
val history = viewModel.historyInfo.value.history
PagesThumbnailsSheet.show(

View File

@@ -62,6 +62,7 @@ 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
@@ -102,6 +103,7 @@ class DetailsFragment :
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()
@@ -324,6 +326,10 @@ class DetailsFragment :
)
}
R.id.textView_size -> {
LocalInfoDialog.show(parentFragmentManager, manga)
}
R.id.imageView_cover -> {
startActivity(
ImageActivity.newIntent(

View File

@@ -23,6 +23,7 @@ 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
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
class DetailsMenuProvider(
private val activity: FragmentActivity,
@@ -43,6 +44,7 @@ class DetailsMenuProvider(
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
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.isStatsEnabled.value
menu.findItem(R.id.action_favourite).setIcon(
if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline,
)
@@ -101,6 +103,12 @@ class DetailsMenuProvider(
}
}
R.id.action_stats -> {
viewModel.manga.value?.let {
MangaStatsSheet.show(activity.supportFragmentManager, it)
}
}
R.id.action_scrobbling -> {
viewModel.manga.value?.let {
ScrobblingSelectorSheet.show(activity.supportFragmentManager, it, null)

View File

@@ -100,6 +100,10 @@ class DetailsViewModel @Inject constructor(
val favouriteCategories = interactor.observeIsFavourite(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
val isStatsEnabled = settings.observeAsStateFlow(viewModelScope + Dispatchers.Default, AppSettings.KEY_STATS_ENABLED) {
isStatsEnabled
}
val remoteManga = MutableStateFlow<Manga?>(null)
val newChaptersCount = details.flatMapLatest { d ->
@@ -320,6 +324,7 @@ class DetailsViewModel @Inject constructor(
page = 0,
scroll = 0,
percent = percent,
force = true,
)
}
}
@@ -346,6 +351,13 @@ class DetailsViewModel @Inject constructor(
settings.closeTip(DetailsActivity.TIP_BUTTON)
}
fun removeFromHistory() {
launchJob(Dispatchers.Default) {
val handle = historyRepository.delete(setOf(mangaId))
onActionDone.call(ReversibleAction(R.string.removed_from_history, handle))
}
}
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
detailsLoadUseCase.invoke(intent)
.onEachWhile {

View File

@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
import org.koitharu.kotatsu.download.ui.dialog.downloadOptionAD
import org.koitharu.kotatsu.settings.SettingsActivity
class DownloadDialogHelper(
private val host: View,
@@ -57,6 +58,9 @@ class DownloadDialogHelper(
.setCancelable(true)
.setTitle(R.string.download)
.setNegativeButton(android.R.string.cancel)
.setNeutralButton(R.string.settings) { _, _ ->
host.context.startActivity(SettingsActivity.newDownloadsSettingsIntent(host.context))
}
.setItems(options)
.create()
.also { it.show() }

View File

@@ -234,7 +234,7 @@ class ChaptersFragment :
}
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
requireViewBinding().recyclerViewChapters.invalidateItemDecorations()
viewBinding?.recyclerViewChapters?.invalidateItemDecorations()
}
override fun onWindowInsetsChanged(insets: Insets) = Unit

View File

@@ -13,7 +13,9 @@ import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -65,11 +67,12 @@ class PagesFragment :
detailsViewModel.selectedBranch,
) { details, history, branch ->
if (details != null && (details.isLoaded || details.chapters.isNotEmpty())) {
PagesViewModel.State(details, history, branch)
PagesViewModel.State(details.filterChapters(branch), history, branch)
} else {
null
}
}.observe(this, viewModel::updateState)
}.flowOn(Dispatchers.Default)
.observe(this, viewModel::updateState)
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentPagesBinding {

View File

@@ -91,6 +91,7 @@ class DownloadWorker @AssistedInject constructor(
private val localMangaRepository: LocalMangaRepository,
private val mangaDataRepository: MangaDataRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
private val settings: AppSettings,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
notificationFactoryFactory: DownloadNotificationFactory.Factory,
) : CoroutineWorker(appContext, params) {
@@ -182,7 +183,7 @@ class DownloadWorker @AssistedInject constructor(
}
val repo = mangaRepositoryFactory.create(manga.source)
val mangaDetails = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = LocalMangaOutput.getOrCreate(destination, mangaDetails)
output = LocalMangaOutput.getOrCreate(destination, mangaDetails, settings.preferredDownloadFormat)
val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl }
if (coverUrl.isNotEmpty()) {
downloadFile(coverUrl, destination, repo.source).let { file ->

View File

@@ -57,7 +57,7 @@ class MangaSourcesRepository @Inject constructor(
observeIsNsfwDisabled(),
dao.observeEnabled(SourcesSortOrder.MANUAL),
) { skipNsfw, sources ->
sources.count { skipNsfw || !MangaSource(it.source).isNsfw() }
sources.count { !skipNsfw || !MangaSource(it.source).isNsfw() }
}.distinctUntilChanged()
}

View File

@@ -68,9 +68,8 @@ class FavouritesCategoryEditActivity :
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
val order = savedInstanceState.getSerializableCompat<ListSortOrder>(KEY_SORT_ORDER)
if (order != null) {
selectedSortOrder = order
savedInstanceState.getSerializableCompat<ListSortOrder>(KEY_SORT_ORDER)?.let {
selectedSortOrder = it
}
}

View File

@@ -15,8 +15,8 @@ import org.koitharu.kotatsu.core.db.entity.MangaEntity
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE,
)
]
),
],
)
data class HistoryEntity(
@PrimaryKey(autoGenerate = false)
@@ -28,4 +28,5 @@ data class HistoryEntity(
@ColumnInfo(name = "scroll") val scroll: Float,
@ColumnInfo(name = "percent") val percent: Float,
@ColumnInfo(name = "deleted_at") val deletedAt: Long,
@ColumnInfo(name = "chapters") val chaptersCount: Int,
)

View File

@@ -90,10 +90,11 @@ class HistoryRepository @Inject constructor(
.distinctUntilChanged()
}
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int, percent: Float) {
if (shouldSkip(manga)) {
suspend fun addOrUpdate(manga: Manga, chapterId: Long, page: Int, scroll: Int, percent: Float, force: Boolean) {
if (!force && shouldSkip(manga)) {
return
}
assert(manga.chapters != null)
db.withTransaction {
mangaRepository.storeManga(manga)
db.getHistoryDao().upsert(
@@ -105,6 +106,7 @@ class HistoryRepository @Inject constructor(
page = page,
scroll = scroll.toFloat(), // we migrate to int, but decide to not update database
percent = percent,
chaptersCount = manga.chapters?.size ?: -1,
deletedAt = 0L,
),
)

View File

@@ -24,6 +24,7 @@ class HistoryUpdateUseCase @Inject constructor(
page = readerState.page,
scroll = readerState.scroll,
percent = percent,
force = false,
)
}

View File

@@ -30,6 +30,7 @@ class MarkAsReadUseCase @Inject constructor(
page = pages.lastIndex,
scroll = 0,
percent = 1f,
force = true,
)
}

View File

@@ -11,7 +11,9 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkManageIntent
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
@@ -27,6 +29,7 @@ class HistoryListFragment : MangaListFragment() {
super.onViewBindingCreated(binding, savedInstanceState)
RecyclerScrollKeeper(binding.recyclerView).attach()
addMenuProvider(HistoryListMenuProvider(binding.root.context, viewModel))
viewModel.isStatsEnabled.observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
}
override fun onScrolledToEnd() = Unit

View File

@@ -1,6 +1,7 @@
package org.koitharu.kotatsu.history.ui
import android.content.Context
import android.content.Intent
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
@@ -9,6 +10,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.RememberSelectionDialogListener
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
import org.koitharu.kotatsu.stats.ui.StatsActivity
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
@@ -24,6 +26,11 @@ class HistoryListMenuProvider(
menuInflater.inflate(R.menu.opt_history, menu)
}
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_stats)?.isVisible = viewModel.isStatsEnabled.value
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_clear_history -> {
@@ -31,6 +38,11 @@ class HistoryListMenuProvider(
true
}
R.id.action_stats -> {
context.startActivity(Intent(context, StatsActivity::class.java))
true
}
else -> false
}
}

View File

@@ -71,6 +71,12 @@ class HistoryListViewModel @Inject constructor(
g && s.isGroupingSupported()
}
val isStatsEnabled = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_STATS_ENABLED,
valueProducer = { isStatsEnabled },
)
override val content = combine(
sortOrder.flatMapLatest { repository.observeAllWithHistory(it) },
isGroupingEnabled,

View File

@@ -18,3 +18,5 @@ fun File.hasCbzExtension() = isCbzExtension(extension)
fun Uri.isZipUri() = scheme.let {
it == URI_SCHEME_ZIP || it == "cbz" || it == "zip"
}
fun Uri.isFileUri() = scheme == "file"

View File

@@ -5,6 +5,7 @@ import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.util.AlphanumComparator
import org.koitharu.kotatsu.core.util.ext.children
import org.koitharu.kotatsu.core.util.ext.creationTime
import org.koitharu.kotatsu.core.util.ext.longHashCode
import org.koitharu.kotatsu.core.util.ext.toListSorted
@@ -100,8 +101,8 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
val file = chapter.url.toUri().toFile()
if (file.isDirectory) {
file.walkCompat()
.filter { hasImageExtension(it) }
file.children()
.filter { it.isFile && hasImageExtension(it) }
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
.map {
val pageUri = it.toUri().toString()
@@ -129,14 +130,16 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
private fun String.toHumanReadable() = replace("_", " ").toCamelCase()
private fun getChaptersFiles() = root.walkCompat()
.filter { it.hasCbzExtension() }
private fun getChaptersFiles() = root.walkCompat(includeDirectories = true)
.filter { it != root && it.isChapterDirectory() || it.hasCbzExtension() }
.associateByTo(TreeMap(AlphanumComparator())) { it.name }
private fun findFirstImageEntry(): String? {
return root.walkCompat().firstOrNull { hasImageExtension(it) }?.toUri()?.toString()
return root.walkCompat(includeDirectories = false)
.firstOrNull { hasImageExtension(it) }?.toUri()?.toString()
?: run {
val cbz = root.walkCompat().firstOrNull { it.hasCbzExtension() } ?: return null
val cbz = root.walkCompat(includeDirectories = false)
.firstOrNull { it.hasCbzExtension() } ?: return null
ZipFile(cbz).use { zip ->
zip.entries().asSequence()
.firstOrNull { !it.isDirectory && hasImageExtension(it.name) }
@@ -148,4 +151,8 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
private fun fileUri(base: File, name: String): String {
return File(base, name).toUri().toString()
}
private fun File.isChapterDirectory(): Boolean {
return isDirectory && children().any { hasImageExtension(it) }
}
}

View File

@@ -4,7 +4,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.internal.format
import okio.Closeable
import org.koitharu.kotatsu.core.prefs.DownloadFormat
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
import org.koitharu.kotatsu.parsers.model.Manga
@@ -35,22 +37,32 @@ sealed class LocalMangaOutput(
const val SUFFIX_TMP = ".tmp"
private val mutex = Mutex()
suspend fun getOrCreate(root: File, manga: Manga): LocalMangaOutput = withContext(Dispatchers.IO) {
val preferSingleCbz = manga.chapters.let {
it != null && it.size <= 3
suspend fun getOrCreate(
root: File,
manga: Manga,
format: DownloadFormat,
): LocalMangaOutput = withContext(Dispatchers.IO) {
val targetFormat = if (format == DownloadFormat.AUTOMATIC) {
if (manga.chapters.let { it != null && it.size <= 3 }) {
DownloadFormat.SINGLE_CBZ
} else {
DownloadFormat.MULTIPLE_CBZ
}
} else {
format
}
checkNotNull(getImpl(root, manga, onlyIfExists = false, preferSingleCbz))
checkNotNull(getImpl(root, manga, onlyIfExists = false, format = targetFormat))
}
suspend fun get(root: File, manga: Manga): LocalMangaOutput? = withContext(Dispatchers.IO) {
getImpl(root, manga, onlyIfExists = true, preferSingleCbz = false)
getImpl(root, manga, onlyIfExists = true, format = DownloadFormat.AUTOMATIC)
}
private suspend fun getImpl(
root: File,
manga: Manga,
onlyIfExists: Boolean,
preferSingleCbz: Boolean,
format: DownloadFormat,
): LocalMangaOutput? {
mutex.withLock {
var i = 0
@@ -75,10 +87,10 @@ sealed class LocalMangaOutput(
continue
}
!onlyIfExists -> if (preferSingleCbz) {
LocalMangaZipOutput(zip, manga)
} else {
LocalMangaDirOutput(dir, manga)
!onlyIfExists -> when (format) {
DownloadFormat.AUTOMATIC -> null
DownloadFormat.SINGLE_CBZ -> LocalMangaZipOutput(zip, manga)
DownloadFormat.MULTIPLE_CBZ -> LocalMangaDirOutput(dir, manga)
}
else -> null

View File

@@ -0,0 +1,90 @@
package org.koitharu.kotatsu.local.ui.info
import android.content.res.ColorStateList
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.widget.TextViewCompat
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView
import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.DialogLocalInfoBinding
import org.koitharu.kotatsu.parsers.model.Manga
import com.google.android.material.R as materialR
@AndroidEntryPoint
class LocalInfoDialog : AlertDialogFragment<DialogLocalInfoBinding>() {
private val viewModel: LocalInfoViewModel by viewModels()
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setTitle(R.string.saved_manga)
.setNegativeButton(R.string.close, null)
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogLocalInfoBinding {
return DialogLocalInfoBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: DialogLocalInfoBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
viewModel.path.observe(this) {
binding.textViewPath.text = it
}
combine(viewModel.size, viewModel.availableSize, ::Pair).observe(this) {
if (it.first >= 0 && it.second >= 0) {
setSegments(it.first, it.second)
} else {
binding.barView.animateSegments(emptyList())
}
}
}
private fun setSegments(size: Long, available: Long) {
val view = viewBinding?.barView ?: return
val total = size + available
val segment = SegmentedBarView.Segment(
percent = (size.toDouble() / total.toDouble()).toFloat(),
color = KotatsuColors.segmentColor(view.context, materialR.attr.colorPrimary),
)
requireViewBinding().labelUsed.text = view.context.getString(
R.string.memory_usage_pattern,
getString(R.string.this_manga),
FileSize.BYTES.format(view.context, size),
)
requireViewBinding().labelAvailable.text = view.context.getString(
R.string.memory_usage_pattern,
getString(R.string.available),
FileSize.BYTES.format(view.context, available),
)
TextViewCompat.setCompoundDrawableTintList(
requireViewBinding().labelUsed,
ColorStateList.valueOf(segment.color),
)
view.animateSegments(listOf(segment))
}
companion object {
const val ARG_MANGA = "manga"
private const val TAG = "LocalInfoDialog"
fun show(fm: FragmentManager, manga: Manga) {
LocalInfoDialog().withArgs(1) {
putParcelable(ARG_MANGA, ParcelableManga(manga))
}.showDistinct(fm, TAG)
}
}
}

View File

@@ -0,0 +1,41 @@
package org.koitharu.kotatsu.local.ui.info
import androidx.core.net.toFile
import androidx.core.net.toUri
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView
import org.koitharu.kotatsu.core.util.ext.computeSize
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.local.data.LocalStorageManager
import javax.inject.Inject
@HiltViewModel
class LocalInfoViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val localMangaRepository: LocalMangaRepository,
private val storageManager: LocalStorageManager,
) : BaseViewModel() {
private val manga = savedStateHandle.require<ParcelableManga>(LocalInfoDialog.ARG_MANGA).manga
val path = MutableStateFlow<String?>(null)
val size = MutableStateFlow(-1L)
val availableSize = MutableStateFlow(-1L)
init {
launchLoadingJob(Dispatchers.Default) {
val file = manga.url.toUri().toFileOrNull() ?: localMangaRepository.findSavedManga(manga)?.file
requireNotNull(file)
path.value = file.path
size.value = file.computeSize()
availableSize.value = storageManager.computeAvailableSize()
}
}
}

View File

@@ -12,6 +12,7 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomnavigation.BottomNavigationView
import com.google.android.material.navigation.NavigationBarView
import com.google.android.material.transition.MaterialFadeThrough
import kotlinx.coroutines.Dispatchers
@@ -34,6 +35,7 @@ import org.koitharu.kotatsu.local.ui.LocalListFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
import org.koitharu.kotatsu.tracker.ui.feed.FeedFragment
import java.util.LinkedList
import com.google.android.material.R as materialR
private const val TAG_PRIMARY = "primary"
@@ -194,12 +196,15 @@ class MainNavigationDelegate(
private fun observeSettings(lifecycleOwner: LifecycleOwner) {
settings.observe()
.filter { x -> x == AppSettings.KEY_TRACKER_ENABLED || x == AppSettings.KEY_SUGGESTIONS }
.filter { x ->
x == AppSettings.KEY_TRACKER_ENABLED || x == AppSettings.KEY_SUGGESTIONS || x == AppSettings.KEY_NAV_LABELS
}
.onStart { emit("") }
.flowOn(Dispatchers.Default)
.flowOn(Dispatchers.IO)
.onEach {
setItemVisibility(R.id.nav_suggestions, settings.isSuggestionsEnabled)
setItemVisibility(R.id.nav_feed, settings.isTrackerEnabled)
setNavbarIsLabeled(settings.isNavLabelsVisible)
}.launchIn(lifecycleOwner.lifecycleScope)
}
@@ -211,6 +216,23 @@ class MainNavigationDelegate(
return null
}
private fun setNavbarIsLabeled(value: Boolean) {
if (navBar is BottomNavigationView) {
navBar.minimumHeight = navBar.resources.getDimensionPixelSize(
if (value) {
materialR.dimen.m3_bottom_nav_min_height
} else {
R.dimen.nav_bar_height_compact
},
)
}
navBar.labelVisibilityMode = if (value) {
NavigationBarView.LABEL_VISIBILITY_LABELED
} else {
NavigationBarView.LABEL_VISIBILITY_UNLABELED
}
}
interface OnFragmentChangedListener {
fun onFragmentChanged(fragment: Fragment, fromUser: Boolean)

View File

@@ -44,6 +44,12 @@ class ProtectActivity :
viewBinding.buttonNext.setOnClickListener(this)
viewBinding.buttonCancel.setOnClickListener(this)
viewBinding.editPassword.inputType = if (viewModel.isNumericPassword) {
EditorInfo.TYPE_CLASS_NUMBER or EditorInfo.TYPE_NUMBER_VARIATION_PASSWORD
} else {
EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_PASSWORD
}
viewModel.onError.observeEvent(this, this::onError)
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.onUnlockSuccess.observeEvent(this) {

View File

@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.parsers.util.isNumeric
import org.koitharu.kotatsu.parsers.util.md5
import javax.inject.Inject
@@ -26,6 +27,9 @@ class ProtectViewModel @Inject constructor(
val isBiometricEnabled
get() = settings.isBiometricProtectionEnabled
val isNumericPassword
get() = settings.isAppPasswordNumeric
fun tryUnlock(password: String) {
if (job?.isActive == true) {
return

View File

@@ -61,6 +61,8 @@ class WelcomeViewModel @Inject constructor(
selectedItems = selectedLocales,
isLoading = false,
)
repository.assimilateNewSources()
commit()
}
}

View File

@@ -47,6 +47,7 @@ import org.koitharu.kotatsu.core.util.ext.ramAvailable
import org.koitharu.kotatsu.core.util.ext.withProgress
import org.koitharu.kotatsu.core.util.progress.ProgressDeferred
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.isFileUri
import org.koitharu.kotatsu.local.data.isZipUri
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -203,20 +204,23 @@ class PageLoader @Inject constructor(
val pageUrl = getPageUrl(page)
check(pageUrl.isNotBlank()) { "Cannot obtain full image url for $page" }
val uri = Uri.parse(pageUrl)
return if (uri.isZipUri()) {
if (uri.scheme == URI_SCHEME_ZIP) {
return when {
uri.isZipUri() -> if (uri.scheme == URI_SCHEME_ZIP) {
uri
} else { // legacy uri
uri.buildUpon().scheme(URI_SCHEME_ZIP).build()
}
} else {
val request = createPageRequest(page, pageUrl)
imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response ->
val body = checkNotNull(response.body) { "Null response body" }
body.withProgress(progress).use {
cache.put(pageUrl, it.source())
}
}.toUri()
uri.isFileUri() -> uri
else -> {
val request = createPageRequest(page, pageUrl)
imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response ->
val body = checkNotNull(response.body) { "Null response body" }
body.withProgress(progress).use {
cache.put(pageUrl, it.source())
}
}.toUri()
}
}
}

View File

@@ -8,19 +8,26 @@ import android.provider.DocumentsContract
import android.webkit.MimeTypeMap
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.net.toUri
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import java.io.File
class PageSaveContract : ActivityResultContracts.CreateDocument("image/*") {
override fun createIntent(context: Context, input: String): Intent {
val intent = super.createIntent(context, input)
val intent = super.createIntent(context, input.substringAfterLast(File.separatorChar))
intent.type = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(input.substringAfterLast('.')) ?: "image/*"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val defaultUri = input.toUriOrNull()?.run {
path?.let { p ->
buildUpon().path(p.substringBeforeLast('/')).build()
}
}
intent.putExtra(
DocumentsContract.EXTRA_INITIAL_URI,
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).toUri(),
defaultUri ?: Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).toUri(),
)
}
return intent
}
}
}

View File

@@ -15,6 +15,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
import okio.IOException
import okio.buffer
import okio.sink
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
@@ -30,7 +31,8 @@ private const val MAX_FILENAME_LENGTH = 10
private const val EXTENSION_FALLBACK = "png"
class PageSaveHelper @Inject constructor(
@ApplicationContext context: Context,
@ApplicationContext private val context: Context,
private val settings: AppSettings,
) {
private var continuation: Continuation<Uri>? = null
@@ -44,14 +46,7 @@ class PageSaveHelper @Inject constructor(
val pageUrl = pageLoader.getPageUrl(page)
val pageUri = pageLoader.loadPage(page, force = false)
val proposedName = getProposedFileName(pageUrl, pageUri)
val destination = withContext(Dispatchers.Main) {
suspendCancellableCoroutine { cont ->
continuation = cont
saveLauncher.launch(proposedName)
}.also {
continuation = null
}
}
val destination = getDefaultFileUri(proposedName) ?: pickFileUri(saveLauncher, proposedName)
runInterruptible(Dispatchers.IO) {
contentResolver.openOutputStream(destination)?.sink()?.buffer()
}?.use { output ->
@@ -62,12 +57,35 @@ class PageSaveHelper @Inject constructor(
return destination
}
private fun getDefaultFileUri(proposedName: String): Uri? {
if (settings.isPagesSavingAskEnabled) {
return null
}
return settings.getPagesSaveDir(context)?.let {
val ext = proposedName.substringAfterLast('.', "")
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return null
it.createFile(mime, proposedName.substringBeforeLast('.'))?.uri
}
}
private suspend fun pickFileUri(saveLauncher: ActivityResultLauncher<String>, proposedName: String): Uri {
val defaultUri = settings.getPagesSaveDir(context)?.uri?.buildUpon()?.appendPath(proposedName)?.toString()
return withContext(Dispatchers.Main) {
suspendCancellableCoroutine { cont ->
continuation = cont
saveLauncher.launch(defaultUri ?: proposedName)
}.also {
continuation = null
}
}
}
fun onActivityResult(uri: Uri): Boolean = continuation?.apply {
resume(uri)
} != null
private suspend fun getProposedFileName(url: String, fileUri: Uri): String {
var name = if (url.startsWith("cbz://")) {
var name = if (url.startsWith("cbz:")) {
requireNotNull(url.toUri().fragment)
} else {
url.toHttpUrl().pathSegments.last()

View File

@@ -14,6 +14,7 @@ import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
import android.view.WindowManager
import androidx.activity.result.ActivityResultCallback
import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.OnApplyWindowInsetsListener
@@ -74,6 +75,7 @@ class ReaderActivity :
ReaderControlDelegate.OnInteractionListener,
OnApplyWindowInsetsListener,
IdlingDetector.Callback,
ActivityResultCallback<Uri?>,
ZoomControl.ZoomControlListener {
@Inject
@@ -83,6 +85,7 @@ class ReaderActivity :
lateinit var tapGridSettings: TapGridSettings
private val idlingDetector = IdlingDetector(TimeUnit.SECONDS.toMillis(10), this)
private val savePageRequest = registerForActivityResult(PageSaveContract(), this)
private val viewModel: ReaderViewModel by viewModels()
@@ -158,6 +161,10 @@ class ReaderActivity :
viewBinding.toolbarBottom.addMenuProvider(ReaderBottomMenuProvider(this, readerManager, viewModel))
}
override fun onActivityResult(result: Uri?) {
viewModel.onActivityResult(result)
}
override fun getParentActivityIntent(): Intent? {
val manga = viewModel.manga?.toManga() ?: return null
return DetailsActivity.newIntent(this, manga)
@@ -169,6 +176,11 @@ class ReaderActivity :
idlingDetector.onUserInteraction()
}
override fun onPause() {
super.onPause()
viewModel.onPause()
}
override fun onIdle() {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
}
@@ -371,6 +383,11 @@ class ReaderActivity :
return reader.isResumed && supportFragmentManager.fragments.lastOrNull() === reader
}
override fun onSavePageClick() {
val page = viewModel.getCurrentPage() ?: return
viewModel.saveCurrentPage(page, savePageRequest)
}
private fun onReaderBarChanged(isBarEnabled: Boolean) {
viewBinding.infoBar.isVisible = isBarEnabled && viewBinding.appbarTop.isGone
}

View File

@@ -114,7 +114,7 @@ class ReaderControlDelegate(
}
private fun isReaderTapsReversed(): Boolean {
return listener.readerMode == ReaderMode.REVERSED
return settings.isReaderControlAlwaysLTR && listener.readerMode == ReaderMode.REVERSED
}
interface OnInteractionListener {

View File

@@ -25,8 +25,7 @@ class ReaderManager(
private val modeMap = EnumMap<ReaderMode, Class<out BaseReaderFragment<*>>>(ReaderMode::class.java)
init {
val useDoublePages = container.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
&& settings.isReaderDoubleOnLandscape
val useDoublePages = isLandscape() && settings.isReaderDoubleOnLandscape
invalidateTypesMap(useDoublePages)
}
@@ -49,7 +48,7 @@ class ReaderManager(
fun setDoubleReaderMode(isEnabled: Boolean) {
val prevMode = currentMode
invalidateTypesMap(isEnabled)
invalidateTypesMap(isEnabled && isLandscape())
val newMode = currentMode ?: return
if (newMode != prevMode) {
replace(newMode)
@@ -70,4 +69,6 @@ class ReaderManager(
modeMap[ReaderMode.WEBTOON] = WebtoonReaderFragment::class.java
modeMap[ReaderMode.VERTICAL] = VerticalReaderFragment::class.java
}
private fun isLandscape() = container.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
}

View File

@@ -58,6 +58,7 @@ import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.stats.domain.StatsCollector
import java.time.Instant
import javax.inject.Inject
@@ -78,11 +79,11 @@ class ReaderViewModel @Inject constructor(
private val detailsLoadUseCase: DetailsLoadUseCase,
private val historyUpdateUseCase: HistoryUpdateUseCase,
private val detectReaderModeUseCase: DetectReaderModeUseCase,
private val statsCollector: StatsCollector,
) : BaseViewModel() {
private val intent = MangaIntent(savedStateHandle)
private val preselectedBranch = savedStateHandle.get<String>(ReaderActivity.EXTRA_BRANCH)
private val isIncognito = savedStateHandle.get<Boolean>(ReaderActivity.EXTRA_INCOGNITO) ?: false
private var loadingJob: Job? = null
private var pageSaveJob: Job? = null
@@ -98,7 +99,7 @@ class ReaderViewModel @Inject constructor(
val onShowToast = MutableEventFlow<Int>()
val uiState = MutableStateFlow<ReaderUiState?>(null)
val incognitoMode = if (isIncognito) {
val incognitoMode = if (savedStateHandle.get<Boolean>(ReaderActivity.EXTRA_INCOGNITO) == true) {
MutableStateFlow(true)
} else mangaFlow.map {
it != null && historyRepository.shouldSkip(it)
@@ -190,6 +191,12 @@ class ReaderViewModel @Inject constructor(
loadImpl()
}
fun onPause() {
manga?.let {
statsCollector.onPause(it.id)
}
}
fun switchMode(newMode: ReaderMode) {
launchJob {
val manga = checkNotNull(mangaData.value?.toManga())
@@ -208,7 +215,7 @@ class ReaderViewModel @Inject constructor(
if (state != null) {
currentState.value = state
}
if (isIncognito) {
if (incognitoMode.value) {
return
}
val readerState = state ?: currentState.value ?: return
@@ -377,7 +384,7 @@ class ReaderViewModel @Inject constructor(
chaptersLoader.loadSingleChapter(requireNotNull(currentState.value).chapterId)
// save state
if (!isIncognito) {
if (!incognitoMode.value) {
currentState.value?.let {
val percent = computePercent(it.chapterId, it.page)
historyUpdateUseCase.invoke(manga, it, percent)
@@ -426,6 +433,9 @@ class ReaderViewModel @Inject constructor(
percent = computePercent(state.chapterId, state.page),
)
uiState.value = newState
if (!incognitoMode.value) {
statsCollector.onStateChanged(m.id, state)
}
}
private fun computePercent(chapterId: Long, pageIndex: Int): Float {

View File

@@ -39,14 +39,12 @@ import javax.inject.Inject
@AndroidEntryPoint
class ReaderConfigSheet :
BaseAdaptiveSheet<SheetReaderConfigBinding>(),
ActivityResultCallback<Uri?>,
View.OnClickListener,
MaterialButtonToggleGroup.OnButtonCheckedListener,
Slider.OnChangeListener,
CompoundButton.OnCheckedChangeListener {
private val viewModel by activityViewModels<ReaderViewModel>()
private val savePageRequest = registerForActivityResult(PageSaveContract(), this)
@Inject
lateinit var orientationHelper: ScreenOrientationHelper
@@ -115,8 +113,8 @@ class ReaderConfigSheet :
}
R.id.button_save_page -> {
val page = viewModel.getCurrentPage() ?: return
viewModel.saveCurrentPage(page, savePageRequest)
findCallback()?.onSavePageClick() ?: return
dismissAllowingStateLoss()
}
R.id.button_screen_rotate -> {
@@ -180,11 +178,6 @@ class ReaderConfigSheet :
(viewBinding ?: return).labelTimerValue.text = getString(R.string.speed_value, value * 10f)
}
override fun onActivityResult(result: Uri?) {
viewModel.onActivityResult(result)
dismissAllowingStateLoss()
}
private fun observeScreenOrientation() {
orientationHelper.observeAutoOrientation()
.onEach {
@@ -214,6 +207,8 @@ class ReaderConfigSheet :
fun onReaderModeChanged(mode: ReaderMode)
fun onDoubleModeChanged(isEnabled: Boolean)
fun onSavePageClick()
}
companion object {

View File

@@ -108,7 +108,7 @@ abstract class BasePagerReaderFragment : BaseReaderFragment<FragmentReaderPagerB
val axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
val withCtrl = event.metaState and KeyEvent.META_CTRL_MASK != 0
if (!withCtrl) {
switchPageBy(-axisValue.sign.toInt())
onWheelScroll(axisValue)
return true
}
}
@@ -172,6 +172,10 @@ abstract class BasePagerReaderFragment : BaseReaderFragment<FragmentReaderPagerB
)
}
protected open fun onWheelScroll(axisValue: Float) {
switchPageBy(-axisValue.sign.toInt())
}
protected open fun onCreateAdvancedTransformer(): PageTransformer = PageAnimTransformer()
protected open fun onInitPager(pager: ViewPager2) {

View File

@@ -22,6 +22,9 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
private val differ = AsyncListDiffer(this, DiffCallback())
val hasItems: Boolean
get() = itemCount != 0
init {
stateRestorationPolicy = StateRestorationPolicy.PREVENT
}

View File

@@ -29,7 +29,11 @@ abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>(), ZoomCont
readerAdapter = onCreateAdapter()
viewModel.content.observe(viewLifecycleOwner) {
onPagesChanged(it.pages, restoredState ?: it.state)
var pendingState = restoredState ?: it.state
if (pendingState == null && it.pages.isNotEmpty() && readerAdapter?.hasItems != true) {
pendingState = viewModel.getCurrentState()
}
onPagesChanged(it.pages, pendingState)
restoredState = null
}
}

View File

@@ -2,13 +2,18 @@ package org.koitharu.kotatsu.reader.ui.pager.reversed
import androidx.viewpager2.widget.ViewPager2
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BasePagerReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import javax.inject.Inject
@AndroidEntryPoint
class ReversedReaderFragment : BasePagerReaderFragment() {
@Inject
lateinit var settings: AppSettings
override fun onCreateAdvancedTransformer(): ViewPager2.PageTransformer = ReversedPageAnimTransformer()
override fun onCreateAdapter() = ReversedPagesAdapter(
@@ -19,6 +24,11 @@ class ReversedReaderFragment : BasePagerReaderFragment() {
exceptionResolver = exceptionResolver,
)
override fun onWheelScroll(axisValue: Float) {
val value = if (settings.isReaderControlAlwaysLTR) -axisValue else axisValue
super.onWheelScroll(value)
}
override fun switchPageBy(delta: Int) {
super.switchPageBy(-delta)
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Matrix
import android.graphics.Point
import android.graphics.Rect
import android.graphics.RectF
import android.util.AttributeSet
@@ -19,12 +20,16 @@ import android.widget.OverScroller
import androidx.core.animation.doOnEnd
import androidx.core.view.GestureDetectorCompat
import androidx.core.view.ViewConfigurationCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.widgets.ZoomControl
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import kotlin.math.roundToInt
private const val MAX_SCALE = 2.5f
private const val MIN_SCALE = 0.5f
private const val FLING_RANGE = 20_000
class WebtoonScalingFrame @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@@ -36,6 +41,7 @@ class WebtoonScalingFrame @JvmOverloads constructor(
private val scaleDetector = ScaleGestureDetector(context, this)
private val gestureDetector = GestureDetectorCompat(context, GestureListener())
private val overScroller = OverScroller(context, AccelerateDecelerateInterpolator())
private val transformMatrix = Matrix()
private val matrixValues = FloatArray(9)
private val scale
@@ -49,6 +55,7 @@ class WebtoonScalingFrame @JvmOverloads constructor(
private val translateBounds = RectF()
private val targetHitRect = Rect()
private var animator: ValueAnimator? = null
private var pendingScroll = 0
var isZoomEnable = false
set(value) {
@@ -80,7 +87,7 @@ class WebtoonScalingFrame @JvmOverloads constructor(
overScroller.forceFinished(true)
}
gestureDetector.onTouchEvent(ev)
val consumed = gestureDetector.onTouchEvent(ev)
scaleDetector.onTouchEvent(ev)
// Offset event to inside the child view
@@ -88,11 +95,7 @@ class WebtoonScalingFrame @JvmOverloads constructor(
ev.offsetLocation(halfWidth - ev.x + targetHitRect.width() / 3, 0f)
}
// Send action cancel to avoid recycler jump when scale end
if (scaleDetector.isInProgress) {
ev.action = MotionEvent.ACTION_CANCEL
}
return super.dispatchTouchEvent(ev)
return consumed || scaleDetector.isInProgress || super.dispatchTouchEvent(ev)
}
override fun onGenericMotionEvent(event: MotionEvent): Boolean {
@@ -178,6 +181,10 @@ class WebtoonScalingFrame @JvmOverloads constructor(
scaleY = scale
translationX = transX
translationY = transY
if (pendingScroll != 0) {
nestedScrollBy(0, pendingScroll)
pendingScroll = 0
}
}
val newHeight = if (scale < 1f) (height / scale).toInt() else height
@@ -210,6 +217,7 @@ class WebtoonScalingFrame @JvmOverloads constructor(
else -> 0f
}
pendingScroll = if (scale > 1) (dy / scale).roundToInt() else 0
transformMatrix.postTranslate(dx, dy)
syncMatrixValues()
}
@@ -277,6 +285,7 @@ class WebtoonScalingFrame @JvmOverloads constructor(
private fun findTargetChild() = getChildAt(0) as WebtoonRecyclerView
private inner class GestureListener : GestureDetector.SimpleOnGestureListener(), Runnable {
private val prevPos = Point()
override fun onScroll(
e1: MotionEvent?,
@@ -294,7 +303,7 @@ class WebtoonScalingFrame @JvmOverloads constructor(
val newScale = if (scale != 1f) 1f else MAX_SCALE * 0.8f
ValueAnimator.ofFloat(scale, newScale).run {
interpolator = AccelerateDecelerateInterpolator()
duration = 300
duration = context.getAnimationDuration(R.integer.config_defaultAnimTime)
addUpdateListener {
scaleChild(it.animatedValue as Float, e.x, e.y)
}
@@ -311,15 +320,16 @@ class WebtoonScalingFrame @JvmOverloads constructor(
): Boolean {
if (scale <= 1) return false
prevPos.set(transX.toInt(), transY.toInt())
overScroller.fling(
transX.toInt(),
transY.toInt(),
prevPos.x,
prevPos.y,
velocityX.toInt(),
velocityY.toInt(),
translateBounds.left.toInt(),
translateBounds.right.toInt(),
translateBounds.top.toInt(),
translateBounds.bottom.toInt(),
translateBounds.top.toInt() - FLING_RANGE,
translateBounds.bottom.toInt() + FLING_RANGE,
)
postOnAnimation(this)
return true
@@ -328,9 +338,10 @@ class WebtoonScalingFrame @JvmOverloads constructor(
override fun run() {
if (overScroller.computeScrollOffset()) {
transformMatrix.postTranslate(
overScroller.currX - transX,
overScroller.currY - transY,
overScroller.currX.toFloat() - prevPos.x,
overScroller.currY.toFloat() - prevPos.y
)
prevPos.set(overScroller.currX, overScroller.currY)
invalidateTarget()
postOnAnimation(this)
}

View File

@@ -32,7 +32,8 @@ class TapGridDispatcher(
if (!isDispatching) {
return true
}
return listener.onGridTouch(getArea(event.rawX, event.rawY))
val area = getArea(event.rawX, event.rawY) ?: return false
return listener.onGridTouch(area)
}
override fun onDoubleTapEvent(e: MotionEvent): Boolean {
@@ -42,11 +43,12 @@ class TapGridDispatcher(
override fun onLongPress(event: MotionEvent) {
if (isDispatching) {
listener.onGridLongTouch(getArea(event.rawX, event.rawY))
val area = getArea(event.rawX, event.rawY) ?: return
listener.onGridLongTouch(area)
}
}
private fun getArea(x: Float, y: Float): TapGridArea {
private fun getArea(x: Float, y: Float): TapGridArea? {
val xIndex = (x * 2f / width).roundToInt()
val yIndex = (y * 2f / height).roundToInt()
val area = when (xIndex) {
@@ -73,7 +75,8 @@ class TapGridDispatcher(
else -> null
}
return checkNotNull(area) { "Invalid area ($xIndex, $yIndex)" }
assert(area != null) { "Invalid area ($xIndex, $yIndex)" }
return area
}
interface OnGridTouchListener {

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.reader.ui.thumbnails
import android.content.Context
import android.webkit.MimeTypeMap
import androidx.core.net.toFile
import androidx.core.net.toUri
import coil.ImageLoader
import coil.decode.DataSource
@@ -20,6 +22,7 @@ import org.koitharu.kotatsu.core.network.ImageProxyInterceptor
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.data.isFileUri
import org.koitharu.kotatsu.local.data.isZipUri
import org.koitharu.kotatsu.local.data.util.withExtraCloseable
import org.koitharu.kotatsu.parsers.model.MangaPage
@@ -56,8 +59,8 @@ class MangaPageFetcher(
private suspend fun loadPage(pageUrl: String): SourceResult {
val uri = pageUrl.toUri()
return if (uri.isZipUri()) {
runInterruptible(Dispatchers.IO) {
return when {
uri.isZipUri() -> runInterruptible(Dispatchers.IO) {
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)
SourceResult(
@@ -66,32 +69,48 @@ class MangaPageFetcher(
context = context,
metadata = MangaPageMetadata(page),
),
mimeType = null,
mimeType = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(entry.name.substringAfterLast('.', "")),
dataSource = DataSource.DISK,
)
}
} else {
val request = PageLoader.createPageRequest(page, pageUrl)
imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response ->
check(response.isSuccessful) {
"Invalid response: ${response.code} ${response.message} at $pageUrl"
}
val body = checkNotNull(response.body) {
"Null response"
}
val mimeType = response.mimeType
val file = body.use {
pagesCache.put(pageUrl, it.source())
}
uri.isFileUri() -> runInterruptible(Dispatchers.IO) {
val file = uri.toFile()
SourceResult(
source = ImageSource(
file = file.toOkioPath(),
source = file.source().buffer(),
context = context,
metadata = MangaPageMetadata(page),
),
mimeType = mimeType,
dataSource = DataSource.NETWORK,
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension),
dataSource = DataSource.DISK,
)
}
else -> {
val request = PageLoader.createPageRequest(page, pageUrl)
imageProxyInterceptor.interceptPageRequest(request, okHttpClient).use { response ->
check(response.isSuccessful) {
"Invalid response: ${response.code} ${response.message} at $pageUrl"
}
val body = checkNotNull(response.body) {
"Null response"
}
val mimeType = response.mimeType
val file = body.use {
pagesCache.put(pageUrl, it.source())
}
SourceResult(
source = ImageSource(
file = file.toOkioPath(),
metadata = MangaPageMetadata(page),
),
mimeType = mimeType,
dataSource = DataSource.NETWORK,
)
}
}
}
}

View File

@@ -52,6 +52,7 @@ class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQuery
viewBinding.toolbar.updatePadding(
left = insets.left,
right = insets.right,
top = insets.top
)
viewBinding.container.updatePadding(
bottom = insets.bottom,

View File

@@ -54,7 +54,7 @@ class AppearanceSettingsFragment :
}
summaryProvider = Preference.SummaryProvider<ActivityListPreference> {
val locale = AppCompatDelegate.getApplicationLocales().get(0)
locale?.getDisplayName(locale)?.toTitleCase(locale) ?: getString(R.string.automatic)
locale?.getDisplayName(locale)?.toTitleCase(locale) ?: getString(R.string.follow_system)
}
setDefaultValueCompat("")
}
@@ -105,7 +105,7 @@ class AppearanceSettingsFragment :
.sortedWithSafe(LocaleComparator())
preference.entries = Array(locales.size + 1) { i ->
if (i == 0) {
getString(R.string.automatic)
getString(R.string.follow_system)
} else {
val lc = locales[i - 1]
lc.getDisplayName(lc).toTitleCase(lc)

View File

@@ -1,20 +1,32 @@
package org.koitharu.kotatsu.settings
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile
import androidx.preference.ListPreference
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.DownloadFormat
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.resolveFile
import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog
import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
import org.koitharu.kotatsu.settings.utils.DozeHelper
@@ -25,7 +37,7 @@ class DownloadsSettingsFragment :
BasePreferenceFragment(R.string.downloads),
SharedPreferences.OnSharedPreferenceChangeListener {
private val dozeHelper = DozeHelper(this)
private val dozeHelper = DozeHelper(this)
@Inject
lateinit var storageManager: LocalStorageManager
@@ -33,8 +45,16 @@ class DownloadsSettingsFragment :
@Inject
lateinit var downloadsScheduler: DownloadWorker.Scheduler
private val pickFileTreeLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) {
if (it != null) onDirectoryPicked(it)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_downloads)
findPreference<ListPreference>(AppSettings.KEY_DOWNLOADS_FORMAT)?.run {
entryValues = DownloadFormat.entries.names()
setDefaultValueCompat(DownloadFormat.AUTOMATIC.name)
}
dozeHelper.updatePreference()
}
@@ -42,6 +62,7 @@ class DownloadsSettingsFragment :
super.onViewCreated(view, savedInstanceState)
findPreference<Preference>(AppSettings.KEY_LOCAL_STORAGE)?.bindStorageName()
findPreference<Preference>(AppSettings.KEY_LOCAL_MANGA_DIRS)?.bindDirectoriesCount()
findPreference<Preference>(AppSettings.KEY_PAGES_SAVE_DIR)?.bindPagesDirectory()
settings.subscribe(this)
}
@@ -63,6 +84,10 @@ class DownloadsSettingsFragment :
AppSettings.KEY_DOWNLOADS_WIFI -> {
updateDownloadsConstraints()
}
AppSettings.KEY_PAGES_SAVE_DIR -> {
findPreference<Preference>(AppSettings.KEY_PAGES_SAVE_DIR)?.bindPagesDirectory()
}
}
}
@@ -82,10 +107,27 @@ class DownloadsSettingsFragment :
dozeHelper.startIgnoreDoseActivity()
}
AppSettings.KEY_PAGES_SAVE_DIR -> {
if (!pickFileTreeLauncher.tryLaunch(settings.getPagesSaveDir(preference.context)?.uri)) {
Snackbar.make(
requireView(), R.string.operation_not_supported, Snackbar.LENGTH_SHORT,
).show()
}
true
}
else -> super.onPreferenceTreeClick(preference)
}
}
private fun onDirectoryPicked(uri: Uri) {
storageManager.takePermissions(uri)
val doc = DocumentFile.fromTreeUri(requireContext(), uri)?.takeIf {
it.canWrite()
}
settings.setPagesSaveDir(doc?.uri)
}
private fun Preference.bindStorageName() {
viewLifecycleScope.launch {
val storage = storageManager.getDefaultWriteableDir()
@@ -104,6 +146,16 @@ class DownloadsSettingsFragment :
}
}
private fun Preference.bindPagesDirectory() {
viewLifecycleScope.launch {
val df = withContext(Dispatchers.IO) {
settings.getPagesSaveDir(this@bindPagesDirectory.context)
}
summary = df?.getDisplayPath(this@bindPagesDirectory.context)
?: this@bindPagesDirectory.context.getString(androidx.preference.R.string.not_set)
}
}
private fun updateDownloadsConstraints() {
val preference = findPreference<Preference>(AppSettings.KEY_DOWNLOADS_WIFI)
viewLifecycleScope.launch {
@@ -119,4 +171,9 @@ class DownloadsSettingsFragment :
}
}
}
private fun DocumentFile.getDisplayPath(context: Context): String {
return uri.resolveFile(context)?.path ?: uri.toString()
}
}

View File

@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.settings
import android.accounts.AccountManager
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
@@ -17,6 +16,7 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.scrobbling.anilist.data.AniListRepository
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository
@@ -28,6 +28,9 @@ import org.koitharu.kotatsu.scrobbling.shikimori.data.ShikimoriRepository
import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.sync.ui.SyncSettingsIntent
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.scrobbling.kitsu.ui.KitsuAuthActivity
import org.koitharu.kotatsu.settings.utils.SplitSwitchPreference
import org.koitharu.kotatsu.stats.ui.StatsActivity
import javax.inject.Inject
@AndroidEntryPoint
@@ -51,11 +54,18 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_services)
bindSuggestionsSummary()
findPreference<SplitSwitchPreference>(AppSettings.KEY_STATS_ENABLED)?.let {
it.onContainerClickListener = Preference.OnPreferenceClickListener {
it.context.startActivity(Intent(it.context, StatsActivity::class.java))
true
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bindSuggestionsSummary()
bindStatsSummary()
settings.subscribe(this)
}
@@ -76,6 +86,7 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
when (key) {
AppSettings.KEY_SUGGESTIONS -> bindSuggestionsSummary()
AppSettings.KEY_STATS_ENABLED -> bindStatsSummary()
}
}
@@ -111,7 +122,7 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
AppSettings.KEY_KITSU -> {
if (!kitsuRepository.isAuthorized) {
launchScrobblerAuth(kitsuRepository)
startActivity(Intent(preference.context, KitsuAuthActivity::class.java))
} else {
startActivity(ScrobblerConfigActivity.newIntent(preference.context, ScrobblerService.KITSU))
}
@@ -194,4 +205,10 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services),
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled,
)
}
private fun bindStatsSummary() {
findPreference<Preference>(AppSettings.KEY_STATS_ENABLED)?.setSummary(
if (settings.isStatsEnabled) R.string.enabled else R.string.disabled,
)
}
}

View File

@@ -157,7 +157,7 @@ class SettingsActivity :
ACTION_SOURCES -> SourcesSettingsFragment()
ACTION_MANAGE_DOWNLOADS -> DownloadsSettingsFragment()
ACTION_SOURCE -> SourceSettingsFragment.newInstance(
intent.getSerializableExtraCompat(EXTRA_SOURCE) as? MangaSource ?: MangaSource.LOCAL,
intent.getSerializableExtraCompat(EXTRA_SOURCE) ?: MangaSource.LOCAL,
)
ACTION_MANAGE_SOURCES -> SourcesManageFragment()

View File

@@ -59,6 +59,7 @@ class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnLis
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setTitle(R.string.restore_backup)
.setCancelable(false)
}

View File

@@ -12,6 +12,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.parsers.util.isNumeric
import org.koitharu.kotatsu.parsers.util.md5
import javax.inject.Inject
@@ -39,6 +40,7 @@ class ProtectSetupViewModel @Inject constructor(
} else {
if (firstPassword.value == password) {
settings.appPassword = password.md5()
settings.isAppPasswordNumeric = password.isNumeric()
onPasswordSet.call(Unit)
} else {
onPasswordMismatch.call(Unit)

View File

@@ -22,7 +22,6 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.util.TaggedActivityResult
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
@@ -43,10 +42,10 @@ class SourceAuthActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallba
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
return
}
val source = intent?.getSerializableExtraCompat(EXTRA_SOURCE) as? MangaSource
val source = intent?.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)
if (source == null) {
finishAfterTransition()
return

View File

@@ -3,19 +3,15 @@ package org.koitharu.kotatsu.settings.userdata
import android.content.Context
import android.content.res.ColorStateList
import android.util.AttributeSet
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.StringRes
import androidx.core.graphics.ColorUtils
import androidx.core.widget.TextViewCompat
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
import com.google.android.material.color.MaterialColors
import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView
import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.databinding.PreferenceMemoryUsageBinding
import com.google.android.material.R as materialR
@@ -38,15 +34,15 @@ class StorageUsagePreference @JvmOverloads constructor(
val binding = PreferenceMemoryUsageBinding.bind(holder.itemView)
val storageSegment = SegmentedBarView.Segment(
usage?.savedManga?.percent ?: 0f,
segmentColor(materialR.attr.colorPrimary),
KotatsuColors.segmentColor(context, materialR.attr.colorPrimary),
)
val pagesSegment = SegmentedBarView.Segment(
usage?.pagesCache?.percent ?: 0f,
segmentColor(materialR.attr.colorSecondary),
KotatsuColors.segmentColor(context, materialR.attr.colorSecondary),
)
val otherSegment = SegmentedBarView.Segment(
usage?.otherCache?.percent ?: 0f,
segmentColor(materialR.attr.colorTertiary),
KotatsuColors.segmentColor(context, materialR.attr.colorTertiary),
)
with(binding) {
@@ -81,27 +77,4 @@ class StorageUsagePreference @JvmOverloads constructor(
context.getString(emptyResId)
}
}
private fun getHue(hex: String): Float {
val r = (hex.substring(0, 2).toInt(16)).toFloat()
val g = (hex.substring(2, 4).toInt(16)).toFloat()
val b = (hex.substring(4, 6).toInt(16)).toFloat()
var hue = 0F
if ((r >= g) && (g >= b)) {
hue = 60 * (g - b) / (r - b)
} else if ((g > r) && (r >= b)) {
hue = 60 * (2 - (r - b) / (g - b))
}
return hue
}
@ColorInt
private fun segmentColor(@AttrRes resId: Int): Int {
val colorHex = String.format("%06x", context.getThemeColor(resId))
val hue = getHue(colorHex)
val color = ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
val backgroundColor = context.getThemeColor(materialR.attr.colorSurfaceContainerHigh)
return MaterialColors.harmonize(color, backgroundColor)
}
}

View File

@@ -0,0 +1,32 @@
package org.koitharu.kotatsu.settings.utils
import android.content.Context
import android.util.AttributeSet
import android.view.View
import androidx.preference.PreferenceViewHolder
import androidx.preference.SwitchPreferenceCompat
import org.koitharu.kotatsu.R
class SplitSwitchPreference @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = androidx.preference.R.attr.switchPreferenceCompatStyle,
defStyleRes: Int = 0
) : SwitchPreferenceCompat(context, attrs, defStyleAttr, defStyleRes) {
init {
layoutResource = R.layout.preference_split_switch
}
var onContainerClickListener: OnPreferenceClickListener? = null
private val containerClickListener = View.OnClickListener { v ->
onContainerClickListener?.onPreferenceClick(this)
}
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
holder.findViewById(R.id.press_container)?.setOnClickListener(containerClickListener)
}
}

View File

@@ -0,0 +1,69 @@
package org.koitharu.kotatsu.stats.data
import android.database.sqlite.SQLiteQueryBuilder
import androidx.room.Dao
import androidx.room.MapColumn
import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction
import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.history.data.HistoryEntity
import org.koitharu.kotatsu.history.data.HistoryWithManga
@Dao
abstract class StatsDao {
@Query("SELECT * FROM stats ORDER BY started_at")
abstract suspend fun findAll(): List<StatsEntity>
@Query("SELECT * FROM stats WHERE manga_id = :mangaId ORDER BY started_at")
abstract suspend fun findAll(mangaId: Long): List<StatsEntity>
@Query("SELECT IFNULL(SUM(pages),0) FROM stats WHERE manga_id = :mangaId")
abstract suspend fun getReadPagesCount(mangaId: Long): Int
@Query("SELECT IFNULL(SUM(duration)/SUM(pages), 0) FROM stats WHERE manga_id = :mangaId")
abstract suspend fun getAverageTimePerPage(mangaId: Long): Long
@Query("SELECT IFNULL(SUM(duration)/SUM(pages), 0) FROM stats")
abstract suspend fun getAverageTimePerPage(): Long
@Query("SELECT IFNULL(SUM(duration), 0) FROM stats WHERE manga_id = :mangaId")
abstract suspend fun getReadingTime(mangaId: Long): Long
@Query("SELECT IFNULL(SUM(duration), 0) FROM stats")
abstract suspend fun getTotalReadingTime(): Long
@Query("DELETE FROM stats")
abstract suspend fun clear()
@Upsert
abstract suspend fun upsert(entity: StatsEntity)
suspend fun getDurationStats(fromDate: Long, isNsfw: Boolean?, favouriteCategories: Set<Long>): Map<MangaEntity, Long> {
val conditions = ArrayList<String>()
conditions.add("stats.started_at >= $fromDate")
if (favouriteCategories.isNotEmpty()) {
val ids = favouriteCategories.joinToString(",")
conditions.add("stats.manga_id IN (SELECT manga_id FROM favourites WHERE category_id IN ($ids))")
}
if (isNsfw != null) {
val flag = if (isNsfw) 1 else 0
conditions.add("manga.nsfw = $flag")
}
val where = conditions.joinToString(separator = " AND ")
val query = SimpleSQLiteQuery(
"SELECT manga.*, SUM(duration) AS d FROM stats LEFT JOIN manga ON manga.manga_id = stats.manga_id WHERE $where GROUP BY manga.manga_id ORDER BY d DESC",
)
return getDurationStatsImpl(query)
}
@RawQuery
protected abstract fun getDurationStatsImpl(
query: SupportSQLiteQuery
): Map<@MapColumn("manga") MangaEntity, @MapColumn("d") Long>
}

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.stats.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.history.data.HistoryEntity
@Entity(
tableName = "stats",
primaryKeys = ["manga_id", "started_at"],
foreignKeys = [
ForeignKey(
entity = HistoryEntity::class,
parentColumns = ["manga_id"],
childColumns = ["manga_id"],
onDelete = ForeignKey.CASCADE,
),
],
)
data class StatsEntity(
@ColumnInfo(name = "manga_id") val mangaId: Long,
@ColumnInfo(name = "started_at") val startedAt: Long,
@ColumnInfo(name = "duration") val duration: Long,
@ColumnInfo(name = "pages") val pages: Int,
)

View File

@@ -0,0 +1,74 @@
package org.koitharu.kotatsu.stats.data
import androidx.collection.LongIntMap
import androidx.collection.MutableLongIntMap
import androidx.room.withTransaction
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.stats.domain.StatsPeriod
import org.koitharu.kotatsu.stats.domain.StatsRecord
import java.util.NavigableMap
import java.util.TreeMap
import java.util.concurrent.TimeUnit
import javax.inject.Inject
class StatsRepository @Inject constructor(
private val db: MangaDatabase,
) {
suspend fun getReadingStats(period: StatsPeriod, categories: Set<Long>): List<StatsRecord> {
val fromDate = if (period == StatsPeriod.ALL) {
0L
} else {
System.currentTimeMillis() - TimeUnit.DAYS.toMillis(period.days.toLong())
}
val stats = db.getStatsDao().getDurationStats(fromDate, null, categories)
val result = ArrayList<StatsRecord>(stats.size)
var other = StatsRecord(null, 0)
val total = stats.values.sum()
for ((mangaEntity, duration) in stats) {
val manga = mangaEntity.toManga(emptySet())
val percent = duration.toDouble() / total
if (percent < 0.05) {
other = other.copy(duration = other.duration + duration)
} else {
result += StatsRecord(
manga = manga,
duration = duration,
)
}
}
if (other.duration != 0L) {
result += other
}
return result
}
suspend fun getTimePerPage(mangaId: Long): Long = db.withTransaction {
val dao = db.getStatsDao()
val pages = dao.getReadPagesCount(mangaId)
val time = if (pages >= 10) {
dao.getAverageTimePerPage(mangaId)
} else {
dao.getAverageTimePerPage()
}
time
}
suspend fun getTotalPagesRead(mangaId: Long): Int {
return db.getStatsDao().getReadPagesCount(mangaId)
}
suspend fun getMangaTimeline(mangaId: Long): NavigableMap<Long, Int> {
val entities = db.getStatsDao().findAll(mangaId)
val map = TreeMap<Long, Int>()
for (e in entities) {
map[e.startedAt] = e.pages
}
return map
}
suspend fun clearStats() {
db.getStatsDao().clear()
}
}

View File

@@ -0,0 +1,73 @@
package org.koitharu.kotatsu.stats.domain
import androidx.collection.LongSparseArray
import androidx.collection.set
import dagger.hilt.android.ViewModelLifecycle
import dagger.hilt.android.scopes.ViewModelScoped
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.stats.data.StatsEntity
import javax.inject.Inject
@ViewModelScoped
class StatsCollector @Inject constructor(
private val db: MangaDatabase,
private val settings: AppSettings,
lifecycle: ViewModelLifecycle,
) {
private val viewModelScope = RetainedLifecycleCoroutineScope(lifecycle)
private val stats = LongSparseArray<Entry>(1)
@Synchronized
fun onStateChanged(mangaId: Long, state: ReaderState) {
if (!settings.isStatsEnabled) {
return
}
val now = System.currentTimeMillis()
val entry = stats[mangaId]
if (entry == null) {
stats[mangaId] = Entry(
state = state,
stats = StatsEntity(
mangaId = mangaId,
startedAt = now,
duration = 0,
pages = 0,
),
)
return
}
val pagesDelta = if (entry.state.page != state.page || entry.state.chapterId != state.chapterId) 1 else 0
val newEntry = entry.copy(
stats = StatsEntity(
mangaId = mangaId,
startedAt = entry.stats.startedAt,
duration = now - entry.stats.startedAt,
pages = entry.stats.pages + pagesDelta,
),
)
stats[mangaId] = newEntry
commit(newEntry.stats)
}
@Synchronized
fun onPause(mangaId: Long) {
stats.remove(mangaId)
}
private fun commit(entity: StatsEntity) {
viewModelScope.launch(Dispatchers.Default) {
db.getStatsDao().upsert(entity)
}
}
private data class Entry(
val state: ReaderState,
val stats: StatsEntity,
)
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.stats.domain
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
enum class StatsPeriod(
@StringRes val titleResId: Int,
val days: Int,
) {
DAY(R.string.day, 1),
WEEK(R.string.week, 7),
MONTH(R.string.month, 30),
MONTHS_3(R.string.three_months, 90),
ALL(R.string.all_time, Int.MAX_VALUE),
}

View File

@@ -0,0 +1,35 @@
package org.koitharu.kotatsu.stats.domain
import android.content.Context
import androidx.annotation.ColorInt
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import com.google.android.material.R
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.details.data.ReadingTime
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.concurrent.TimeUnit
import kotlin.math.absoluteValue
data class StatsRecord(
val manga: Manga?,
val duration: Long,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is StatsRecord && other.manga == manga
}
val time: ReadingTime
init {
val minutes = TimeUnit.MILLISECONDS.toMinutes(duration).toInt()
time = ReadingTime(
minutes = minutes % 60,
hours = minutes / 60,
isContinue = false,
)
}
}

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.stats.ui
import android.content.res.ColorStateList
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.KotatsuColors
import org.koitharu.kotatsu.databinding.ItemStatsBinding
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.stats.domain.StatsRecord
fun statsAD(
listener: OnListItemClickListener<Manga>,
) = adapterDelegateViewBinding<StatsRecord, StatsRecord, ItemStatsBinding>(
{ layoutInflater, parent -> ItemStatsBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener { v ->
listener.onItemClick(item.manga ?: return@setOnClickListener, v)
}
bind {
binding.textViewTitle.text = item.manga?.title ?: getString(R.string.other_manga)
binding.textViewSummary.text = item.time.format(context.resources)
binding.imageViewBadge.imageTintList = ColorStateList.valueOf(KotatsuColors.ofManga(context, item.manga))
binding.root.isClickable = item.manga != null
}
}

View File

@@ -0,0 +1,197 @@
package org.koitharu.kotatsu.stats.ui
import android.os.Bundle
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewStub
import android.widget.CompoundButton
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.AsyncListDiffer
import coil.ImageLoader
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
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.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.databinding.ActivityStatsBinding
import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.stats.domain.StatsPeriod
import org.koitharu.kotatsu.stats.domain.StatsRecord
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
import org.koitharu.kotatsu.stats.ui.views.PieChartView
import javax.inject.Inject
@AndroidEntryPoint
class StatsActivity : BaseActivity<ActivityStatsBinding>(),
OnListItemClickListener<Manga>,
PieChartView.OnSegmentClickListener,
AsyncListDiffer.ListListener<StatsRecord>,
ViewStub.OnInflateListener, View.OnClickListener, CompoundButton.OnCheckedChangeListener {
@Inject
lateinit var coil: ImageLoader
private val viewModel: StatsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityStatsBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
val adapter = BaseListAdapter<StatsRecord>()
.addDelegate(ListItemType.FEED, statsAD(this))
.addListListener(this)
viewBinding.recyclerView.adapter = adapter
viewBinding.chart.onSegmentClickListener = this
viewBinding.stubEmpty.setOnInflateListener(this)
viewBinding.chipPeriod.setOnClickListener(this)
viewModel.isLoading.observe(this) {
viewBinding.progressBar.showOrHide(it)
}
viewModel.period.observe(this) {
viewBinding.chipPeriod.setText(it.titleResId)
}
viewModel.favoriteCategories.observe(this, ::createCategoriesChips)
viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.recyclerView))
viewModel.readingStats.observe(this) {
val sum = it.sumOf { it.duration }
viewBinding.chart.setData(
it.map { v ->
PieChartView.Segment(
value = (v.duration / 1000).toInt(),
label = v.manga?.title ?: getString(R.string.other_manga),
percent = (v.duration.toDouble() / sum).toFloat(),
color = KotatsuColors.ofManga(this, v.manga),
tag = v.manga,
)
},
)
adapter.emit(it)
}
}
override fun onWindowInsetsChanged(insets: Insets) = Unit
override fun onClick(v: View) {
when (v.id) {
R.id.chip_period -> showPeriodSelector()
}
}
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
val category = buttonView?.tag as? FavouriteCategory ?: return
viewModel.setCategoryChecked(category, isChecked)
}
override fun onItemClick(item: Manga, view: View) {
MangaStatsSheet.show(supportFragmentManager, item)
}
override fun onSegmentClick(view: PieChartView, segment: PieChartView.Segment) {
val manga = segment.tag as? Manga ?: return
onItemClick(manga, view)
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.opt_stats, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_clear -> {
showClearConfirmDialog()
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onCurrentListChanged(previousList: MutableList<StatsRecord>, currentList: MutableList<StatsRecord>) {
val isEmpty = currentList.isEmpty()
with(viewBinding) {
chart.isGone = isEmpty
recyclerView.isGone = isEmpty
stubEmpty.isVisible = isEmpty
}
}
override fun onInflate(stub: ViewStub?, inflated: View) {
val stubBinding = ItemEmptyStateBinding.bind(inflated)
stubBinding.icon.newImageRequest(this, R.drawable.ic_empty_history)?.enqueueWith(coil)
stubBinding.textPrimary.setText(R.string.text_empty_holder_primary)
stubBinding.textSecondary.setTextAndVisible(R.string.empty_stats_text)
stubBinding.buttonRetry.isVisible = false
}
private fun createCategoriesChips(categories: List<FavouriteCategory>) {
val container = viewBinding.layoutChips
if (container.childCount > 1) {
// avoid duplication
return
}
val checkedIds = viewModel.selectedCategories.value
for (category in categories) {
val chip = Chip(this)
val drawable = ChipDrawable.createFromAttributes(this, null, 0, R.style.Widget_Kotatsu_Chip_Filter)
chip.setChipDrawable(drawable)
chip.text = category.title
chip.tag = category
chip.isChecked = category.id in checkedIds
chip.setOnCheckedChangeListener(this)
container.addView(chip)
}
}
private fun showClearConfirmDialog() {
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
.setMessage(R.string.clear_stats_confirm)
.setTitle(R.string.clear_stats)
.setIcon(R.drawable.ic_delete)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
viewModel.clear()
}.show()
}
private fun showPeriodSelector() {
val menu = PopupMenu(this, viewBinding.chipPeriod)
val selected = viewModel.period.value
for ((i, branch) in StatsPeriod.entries.withIndex()) {
val item = menu.menu.add(R.id.group_period, Menu.NONE, i, branch.titleResId)
item.isCheckable = true
item.isChecked = selected.ordinal == i
}
menu.menu.setGroupCheckable(R.id.group_period, true, true)
menu.setOnMenuItemClickListener {
StatsPeriod.entries.getOrNull(it.order)?.also {
viewModel.period.value = it
} != null
}
menu.show()
}
}

View File

@@ -0,0 +1,73 @@
package org.koitharu.kotatsu.stats.ui
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.stats.data.StatsRepository
import org.koitharu.kotatsu.stats.domain.StatsPeriod
import org.koitharu.kotatsu.stats.domain.StatsRecord
import javax.inject.Inject
@HiltViewModel
class StatsViewModel @Inject constructor(
private val repository: StatsRepository,
private val favouritesRepository: FavouritesRepository,
) : BaseViewModel() {
val period = MutableStateFlow(StatsPeriod.WEEK)
val onActionDone = MutableEventFlow<ReversibleAction>()
val selectedCategories = MutableStateFlow<Set<Long>>(emptySet())
val favoriteCategories = favouritesRepository.observeCategories()
.take(1)
val readingStats = MutableStateFlow<List<StatsRecord>>(emptyList())
init {
launchJob(Dispatchers.Default) {
combine<StatsPeriod, Set<Long>, Pair<StatsPeriod, Set<Long>>>(
period,
selectedCategories,
::Pair,
).collectLatest { p ->
readingStats.value = withLoading {
repository.getReadingStats(p.first, p.second)
}
}
}
}
fun setCategoryChecked(category: FavouriteCategory, checked: Boolean) {
val snapshot = selectedCategories.value.toMutableSet()
if (checked) {
snapshot.add(category.id)
} else {
snapshot.remove(category.id)
}
selectedCategories.value = snapshot
}
fun clear() {
launchLoadingJob(Dispatchers.Default) {
repository.clearStats()
readingStats.value = emptyList()
onActionDone.call(ReversibleAction(R.string.stats_cleared, null))
}
}
}

View File

@@ -0,0 +1,82 @@
package org.koitharu.kotatsu.stats.ui.sheet
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.collection.IntList
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetStatsMangaBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.stats.ui.views.BarChartView
@AndroidEntryPoint
class MangaStatsSheet : BaseAdaptiveSheet<SheetStatsMangaBinding>(), View.OnClickListener {
private val viewModel: MangaStatsViewModel by viewModels()
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetStatsMangaBinding {
return SheetStatsMangaBinding.inflate(inflater, container, false)
}
override fun onViewBindingCreated(binding: SheetStatsMangaBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
binding.textViewTitle.text = viewModel.manga.title
binding.chartView.barColor = KotatsuColors.ofManga(binding.root.context, viewModel.manga)
viewModel.stats.observe(viewLifecycleOwner, ::onStatsChanged)
viewModel.startDate.observe(viewLifecycleOwner) {
binding.textViewStart.textAndVisible = it?.format(resources)
}
viewModel.totalPagesRead.observe(viewLifecycleOwner) {
binding.textViewPages.text = getString(R.string.pages_read_s, it.format())
}
binding.buttonOpen.setOnClickListener(this)
}
override fun onClick(v: View) {
startActivity(DetailsActivity.newIntent(v.context, viewModel.manga))
}
private fun onStatsChanged(stats: IntList) {
val chartView = viewBinding?.chartView ?: return
if (stats.isEmpty()) {
chartView.setData(emptyList())
return
}
val bars = ArrayList<BarChartView.Bar>(stats.size)
stats.forEach { pages ->
bars.add(
BarChartView.Bar(
value = pages,
label = pages.toString(),
),
)
}
chartView.setData(bars)
}
companion object {
const val ARG_MANGA = "manga"
private const val TAG = "MangaStatsSheet"
fun show(fm: FragmentManager, manga: Manga) {
MangaStatsSheet().withArgs(1) {
putParcelable(ARG_MANGA, ParcelableManga(manga))
}.showDistinct(fm, TAG)
}
}
}

View File

@@ -0,0 +1,58 @@
package org.koitharu.kotatsu.stats.ui.sheet
import androidx.collection.IntList
import androidx.collection.LongIntMap
import androidx.collection.MutableIntList
import androidx.collection.emptyIntList
import androidx.collection.emptyLongIntMap
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.stats.data.StatsRepository
import org.koitharu.kotatsu.stats.domain.StatsRecord
import java.time.Instant
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltViewModel
class MangaStatsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: StatsRepository,
) : BaseViewModel() {
val manga = savedStateHandle.require<ParcelableManga>(MangaStatsSheet.ARG_MANGA).manga
val stats = MutableStateFlow<IntList>(emptyIntList())
val startDate = MutableStateFlow<DateTimeAgo?>(null)
val totalPagesRead = MutableStateFlow(0)
init {
launchLoadingJob(Dispatchers.Default) {
val timeline = repository.getMangaTimeline(manga.id)
if (timeline.isEmpty()) {
startDate.value = null
stats.value = emptyIntList()
} else {
val startDay = TimeUnit.MILLISECONDS.toDays(timeline.firstKey())
val endDay = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis())
val res = MutableIntList((endDay - startDay).toInt() + 1)
for (day in startDay..endDay) {
val from = TimeUnit.DAYS.toMillis(day)
val to = TimeUnit.DAYS.toMillis(day + 1)
res.add(timeline.subMap(from, true, to, false).values.sum())
}
stats.value = res
startDate.value = calculateTimeAgo(Instant.ofEpochMilli(timeline.firstKey()))
}
}
launchLoadingJob(Dispatchers.Default) {
totalPagesRead.value = repository.getTotalPagesRead(manga.id)
}
}
}

View File

@@ -0,0 +1,172 @@
package org.koitharu.kotatsu.stats.ui.views
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.DashPathEffect
import android.graphics.Paint
import android.graphics.PathEffect
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import android.graphics.RectF
import android.graphics.Xfermode
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import androidx.annotation.ColorInt
import androidx.collection.MutableIntList
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.minus
import androidx.core.view.GestureDetectorCompat
import androidx.core.view.setPadding
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.parsers.util.replaceWith
import org.koitharu.kotatsu.parsers.util.toIntUp
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.roundToInt
import kotlin.math.sqrt
import kotlin.random.Random
import com.google.android.material.R as materialR
class BarChartView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val rawData = ArrayList<Bar>()
private val bars = ArrayList<Bar>()
private var maxValue: Int = 0
private val minBarSpacing = context.resources.resolveDp(12f)
private val minSpace = context.resources.resolveDp(20f)
private val barWidth = context.resources.resolveDp(12f)
private val outlineColor = context.getThemeColor(materialR.attr.colorOutline)
private val dottedEffect = DashPathEffect(
floatArrayOf(
context.resources.resolveDp(6f),
context.resources.resolveDp(6f),
),
0f,
)
private val chartBounds = RectF()
@ColorInt
var barColor: Int = context.getThemeColor(materialR.attr.colorAccent)
set(value) {
field = value
invalidate()
}
init {
paint.strokeWidth = context.resources.resolveDp(1f)
if (isInEditMode) {
setData(
List(Random.nextInt(20, 60)) {
Bar(
value = Random.nextInt(-20, 400).coerceAtLeast(0),
label = it.toString(),
)
},
)
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (bars.isEmpty() || chartBounds.isEmpty) {
return
}
val spacing = (chartBounds.width() - (barWidth * bars.size.toFloat())) / (bars.size + 1).toFloat()
// dashed horizontal lines
paint.color = outlineColor
paint.style = Paint.Style.STROKE
canvas.drawLine(chartBounds.left, chartBounds.bottom, chartBounds.right, chartBounds.bottom, paint)
paint.pathEffect = dottedEffect
for (i in (0..maxValue).step(computeValueStep())) {
val y = chartBounds.top + (chartBounds.height() * i / maxValue.toFloat())
canvas.drawLine(paddingLeft.toFloat(), y, (width - paddingLeft - paddingRight).toFloat(), y, paint)
}
// bottom line
paint.color = outlineColor
paint.style = Paint.Style.STROKE
canvas.drawLine(chartBounds.left, chartBounds.bottom, chartBounds.right, chartBounds.bottom, paint)
// bars
paint.style = Paint.Style.FILL
paint.color = barColor
paint.pathEffect = null
val corner = barWidth / 2f
for ((i, bar) in bars.withIndex()) {
if (bar.value == 0) {
continue
}
val h = (chartBounds.height() * bar.value / maxValue.toFloat()).coerceAtLeast(barWidth)
val x = spacing + i * (barWidth + spacing) + paddingLeft
canvas.drawRoundRect(x, chartBounds.bottom - h, x + barWidth, chartBounds.bottom, corner, corner, paint)
}
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
invalidateBounds()
}
fun setData(value: List<Bar>) {
rawData.replaceWith(value)
compressBars()
invalidate()
}
private fun compressBars() {
if (rawData.isEmpty() || width <= 0) {
maxValue = 0
bars.clear()
return
}
var fullWidth = rawData.size * (barWidth + minBarSpacing) + minBarSpacing
val windowSize = (fullWidth / width.toFloat()).toIntUp()
bars.replaceWith(
rawData.chunked(windowSize) { it.average() },
)
maxValue = bars.maxOf { it.value }
}
private fun computeValueStep(): Int {
val h = chartBounds.height()
var step = 1
while (h / (maxValue / step).toFloat() <= minSpace) {
step++
}
return step
}
private fun invalidateBounds() {
val inset = paint.strokeWidth
chartBounds.set(
paddingLeft.toFloat() + inset,
paddingTop.toFloat() + inset,
(width - paddingLeft - paddingRight).toFloat() - inset,
(height - paddingTop - paddingBottom).toFloat() - inset,
)
compressBars()
}
private fun Collection<Bar>.average(): Bar {
return when (size) {
0 -> Bar(0, "")
1 -> first()
else -> Bar(
value = (sumOf { it.value } / size.toFloat()).roundToInt(),
label = "%s - %s".format(first().label, last().label),
)
}
}
class Bar(
val value: Int,
val label: String,
)
}

View File

@@ -0,0 +1,171 @@
package org.koitharu.kotatsu.stats.ui.views
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import android.graphics.RectF
import android.graphics.Xfermode
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import androidx.annotation.ColorInt
import androidx.collection.MutableIntList
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.minus
import androidx.core.view.GestureDetectorCompat
import com.google.android.material.color.MaterialColors
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.parsers.util.replaceWith
import kotlin.math.absoluteValue
import kotlin.math.sqrt
import com.google.android.material.R as materialR
class PieChartView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr), GestureDetector.OnGestureListener {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val segments = ArrayList<Segment>()
private val chartBounds = RectF()
private val clearColor = context.getThemeColor(android.R.attr.colorBackground)
private val touchDetector = GestureDetectorCompat(context, this)
private var hightlightedSegment = -1
var onSegmentClickListener: OnSegmentClickListener? = null
init {
touchDetector.setIsLongpressEnabled(false)
paint.strokeWidth = context.resources.resolveDp(2f)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
var angle = 0f
for ((i, segment) in segments.withIndex()) {
paint.color = segment.color
if (i == hightlightedSegment) {
paint.color = ColorUtils.setAlphaComponent(paint.color, 180)
}
paint.style = Paint.Style.FILL
val sweepAngle = segment.percent * 360f
canvas.drawArc(
chartBounds,
angle,
sweepAngle,
true,
paint,
)
paint.color = clearColor
paint.style = Paint.Style.STROKE
canvas.drawArc(
chartBounds,
angle,
sweepAngle,
true,
paint,
)
angle += sweepAngle
}
paint.style = Paint.Style.FILL
paint.color = clearColor
canvas.drawCircle(chartBounds.centerX(), chartBounds.centerY(), chartBounds.height() / 4f, paint)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
val size = minOf(w, h).toFloat()
val inset = paint.strokeWidth
chartBounds.set(inset, inset, size - inset, size - inset)
chartBounds.offset(
(w - size) / 2f,
(h - size) / 2f,
)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_CANCEL || event.actionMasked == MotionEvent.ACTION_UP) {
hightlightedSegment = -1
invalidate()
}
return super.onTouchEvent(event) || touchDetector.onTouchEvent(event)
}
override fun onDown(e: MotionEvent): Boolean {
if (onSegmentClickListener == null) {
return false
}
val segment = findSegmentIndex(e.x, e.y)
if (segment != hightlightedSegment) {
hightlightedSegment = segment
invalidate()
return true
} else {
return false
}
}
override fun onShowPress(e: MotionEvent) = Unit
override fun onSingleTapUp(e: MotionEvent): Boolean {
onSegmentClickListener?.run {
val segment = segments.getOrNull(findSegmentIndex(e.x, e.y))
if (segment != null) {
onSegmentClick(this@PieChartView, segment)
}
}
return true
}
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean = false
override fun onLongPress(e: MotionEvent) = Unit
override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean = false
fun setData(value: List<Segment>) {
segments.replaceWith(value)
invalidate()
}
private fun findSegmentIndex(x: Float, y: Float): Int {
val dy = (y - chartBounds.centerY()).toDouble()
val dx = (x - chartBounds.centerX()).toDouble()
val distance = sqrt(dx * dx + dy * dy).toFloat()
if (distance < chartBounds.height() / 4f || distance > chartBounds.centerX()) {
return -1
}
var touchAngle = Math.toDegrees(Math.atan2(dy, dx)).toFloat()
if (touchAngle < 0) {
touchAngle += 360
}
var angle = 0f
for ((i, segment) in segments.withIndex()) {
val sweepAngle = segment.percent * 360f
if (touchAngle in angle..(angle + sweepAngle)) {
return i
}
angle += sweepAngle
}
return -1
}
class Segment(
val value: Int,
val label: String,
val percent: Float,
val color: Int,
val tag: Any?,
)
interface OnSegmentClickListener {
fun onSegmentClick(view: PieChartView, segment: Segment)
}
}

View File

@@ -293,6 +293,7 @@ class TrackWorker @AssistedInject constructor(
setCategory(NotificationCompat.CATEGORY_SERVICE)
setDefaults(0)
setOngoing(false)
setOnlyAlertOnce(true)
setSilent(true)
setContentIntent(
PendingIntentCompat.getActivity(

View File

@@ -2,6 +2,7 @@
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path

View File

@@ -17,8 +17,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:minHeight="16dp"
android:paddingHorizontal="6dp"
android:textSize="12sp"
android:visibility="gone"
tools:visibility="visible" />

View File

@@ -58,6 +58,7 @@
android:autofillHints="emailAddress"
android:imeOptions="actionDone"
android:inputType="textEmailAddress"
android:maxLength="512"
android:singleLine="true"
android:textSize="16sp"
tools:hint="Email" />
@@ -84,7 +85,7 @@
android:autofillHints="password"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:maxLength="24"
android:maxLength="512"
android:singleLine="true"
android:textSize="16sp"
tools:hint="Password" />

View File

@@ -8,8 +8,7 @@
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true">
android:layout_height="wrap_content">
<com.google.android.material.appbar.MaterialToolbar
android:id="@id/toolbar"

View File

@@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
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"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_scrollFlags="noScroll">
</com.google.android.material.appbar.MaterialToolbar>
</com.google.android.material.appbar.AppBarLayout>
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="gone"
app:hideAnimationBehavior="outward"
app:layout_constraintBottom_toBottomOf="@id/appbar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appbar"
app:showAnimationBehavior="inward"
app:trackCornerRadius="0dp"
tools:visibility="visible" />
<HorizontalScrollView
android:id="@+id/scrollView_chips"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:paddingHorizontal="12dp"
android:scrollbars="none"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appbar">
<com.google.android.material.chip.ChipGroup
android:id="@+id/layout_chips"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<com.google.android.material.chip.Chip
android:id="@+id/chip_period"
style="@style/Widget.Kotatsu.Chip.Dropdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/week"
app:chipIcon="@drawable/ic_history" />
</com.google.android.material.chip.ChipGroup>
</HorizontalScrollView>
<org.koitharu.kotatsu.stats.ui.views.PieChartView
android:id="@+id/chart"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="24dp"
app:layout_constraintDimensionRatio="1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/scrollView_chips" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="24dp"
android:overScrollMode="ifContentScrolls"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/chart"
tools:itemCount="4"
tools:listitem="@layout/item_stats" />
<ViewStub
android:id="@+id/stub_empty"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout="@layout/item_empty_state"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/scrollView_chips"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
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="wrap_content"
android:orientation="vertical"
android:padding="?dialogPreferredPadding">
<TextView
android:id="@+id/textView_path_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:text="@string/location"
android:textAppearance="?textAppearanceLabelMedium" />
<TextView
android:id="@+id/textView_path"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?textAppearanceBodyMedium"
tools:text="/storage/emulated/0/Manga/lorem.cbz" />
<org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView
android:id="@+id/barView"
android:layout_width="match_parent"
android:layout_height="18dp"
android:layout_marginTop="12dp"
android:background="?colorSecondaryContainer" />
<TextView
android:id="@+id/label_used"
style="@style/Widget.Kotatsu.TextView.Indicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal"
android:text="@string/this_manga"
app:drawableStartCompat="@drawable/bg_rounded_square"
tools:drawableTint="?colorPrimary" />
<TextView
android:id="@+id/label_available"
style="@style/Widget.Kotatsu.TextView.Indicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
android:text="@string/available"
app:drawableStartCompat="@drawable/bg_rounded_square"
app:drawableTint="?colorSecondaryContainer" />
</LinearLayout>

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