Compare commits

...

111 Commits

Author SHA1 Message Date
08764cb3cb Translated using Weblate (Russian)
Currently translated at 100.0% (399 of 399 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
Evgeniy Khramov
9c52545f63 Translated using Weblate (Russian)
Currently translated at 100.0% (398 of 398 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (8 of 8 strings)

Co-authored-by: Evgeniy Khramov <thejenjagamertjg@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-12-27 09:00:05 +02:00
gallegonovato
a6c30d33d4 Translated using Weblate (Spanish)
Currently translated at 100.0% (398 of 398 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
Neko Nekowazarashi
25974af229 Translated using Weblate (Indonesian)
Currently translated at 90.2% (359 of 398 strings)

Co-authored-by: Neko Nekowazarashi <kodra@nekoweb.my.id>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
Andy Hong
607dfc9be3 Translated using Weblate (Korean)
Currently translated at 26.8% (107 of 398 strings)

Added translation using Weblate (Korean)

Added translation using Weblate (Korean)

Co-authored-by: Andy Hong <andy963963@icloud.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ko/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
mondstern
560e669700 Translated using Weblate (German)
Currently translated at 97.2% (387 of 398 strings)

Co-authored-by: mondstern <mondstern@snopyta.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
J. Lavoie
ba403c9360 Translated using Weblate (French)
Currently translated at 100.0% (398 of 398 strings)

Translated using Weblate (German)

Currently translated at 96.2% (383 of 398 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
Eric
0f1c9ff05d Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (398 of 398 strings)

Co-authored-by: Eric <hamburger1024@duck.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
kuragehime
662f08e115 Translated using Weblate (Japanese)
Currently translated at 100.0% (398 of 398 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
Oğuz Ersen
d647a32e9f Translated using Weblate (Turkish)
Currently translated at 100.0% (399 of 399 strings)

Translated using Weblate (Turkish)

Currently translated at 100.0% (398 of 398 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2022-12-27 09:00:05 +02:00
375e72cb98 Pass language to voice search 2022-12-27 08:55:39 +02:00
34c7cafdfe Use AlphanumComparator for importing manga dir 2022-12-27 08:39:27 +02:00
NOTMASTER09
03e0eefe4d Fix DirMangaImporter 2022-12-27 08:36:18 +02:00
f41425f03d Popup menu on sources in Explore fragment 2022-12-26 20:02:02 +02:00
400b91278f Allow source login on error 2022-12-26 19:27:38 +02:00
9088f77ae5 Update dependencies 2022-12-26 19:15:27 +02:00
86da3217d1 Add A13 locale list 2022-12-26 19:15:17 +02:00
24908e52af Update network error message 2022-12-09 18:32:10 +02:00
1261a6790d Improve pages cache creation 2022-12-09 18:24:55 +02:00
59fa61864a Update parsers 2022-12-09 18:19:39 +02:00
1cbfe017ea Fix network state observer 2022-11-30 09:22:51 +02:00
f469369b14 Fix manga search suggestions #268 2022-11-30 09:08:40 +02:00
1ddcaed483 Update parsers 2022-11-30 08:29:36 +02:00
Cường Bá
7bb7736f18 Translated using Weblate (Vietnamese)
Currently translated at 87.5% (7 of 8 strings)

Co-authored-by: Cường Bá <cuongba956@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/vi/
Translation: Kotatsu/plurals
2022-11-30 08:11:39 +02:00
john d
d1e7e7a2a6 Translated using Weblate (Greek)
Currently translated at 20.8% (83 of 398 strings)

Translated using Weblate (Greek)

Currently translated at 2.2% (9 of 398 strings)

Translated using Weblate (Greek)

Currently translated at 87.5% (7 of 8 strings)

Added translation using Weblate (Greek)

Added translation using Weblate (Greek)

Co-authored-by: john d <rasengan1405@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/el/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/el/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-11-30 08:11:39 +02:00
0c4b7b0586 Replace ArrayMap with an AndroidX implementation 2022-11-28 19:59:32 +02:00
f320f22863 Improve network state observer 2022-11-28 19:40:01 +02:00
d224cd99bb Update parsers 2022-11-21 09:18:09 +02:00
Blagoje Nikolić
b955d31770 Translated using Weblate (Serbian)
Currently translated at 4.2% (17 of 398 strings)

Translated using Weblate (Serbian)

Currently translated at 100.0% (8 of 8 strings)

Added translation using Weblate (Serbian)

Translated using Weblate (Serbian)

Currently translated at 12.5% (1 of 8 strings)

Added translation using Weblate (Serbian)

Co-authored-by: Blagoje Nikolić <blagojenikolic006@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
2022-11-21 09:17:33 +02:00
b4eb8d56a6 Auto refresh page on connection restored 2022-11-17 19:02:20 +02:00
c896ac72e8 Improve page loading progress displaying 2022-11-12 10:28:47 +02:00
b599cb33ff Improve pages loading #256 2022-11-11 20:09:00 +02:00
b3eab1a2a0 Fix crash on app start in background 2022-11-06 10:38:55 +02:00
79d9dc7b24 Use DummyParser as fallback in release builds 2022-11-06 10:26:14 +02:00
7b573f8e6b Update parsers 2022-11-06 10:14:09 +02:00
Dpper
7bd769e294 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (398 of 398 strings)

Co-authored-by: Dpper <ruslan20020401@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2022-11-03 18:11:37 +02:00
Oğuz Ersen
fde5f86313 Translated using Weblate (Turkish)
Currently translated at 100.0% (398 of 398 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2022-11-03 18:11:37 +02:00
3c23bf7ec9 Remove DialogWhenLarge theme 2022-11-03 18:00:19 +02:00
4665f8b74e Add syncronized to PageLoader.tasks (fix crash) 2022-11-03 17:57:18 +02:00
5a43e677c5 Respect incognito mode in search 2022-10-28 08:21:13 +03:00
38d4274ece Shelf settings 2022-10-28 07:56:29 +03:00
0e5221fa6e Fix NetworkStateObserver 2022-10-24 19:52:30 +03:00
b458bde8a1 Configure shelf sections (2) 2022-10-24 19:40:08 +03:00
c663d10515 Configure shelf sections 2022-10-24 19:32:28 +03:00
cec19c3db3 Fix crash related to slider 2022-10-23 09:18:14 +03:00
J. Lavoie
ff58539e2e Translated using Weblate (French)
Currently translated at 100.0% (398 of 398 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2022-10-22 18:33:34 +03:00
d8e7689a94 Update ssiv 2022-10-22 17:53:53 +03:00
32cfbb327c Fix potential crash related to slider 2022-10-21 17:57:35 +03:00
245e87256b Merge branch 'master' of github.com:KotatsuApp/Kotatsu 2022-10-20 10:38:34 +03:00
ed8c69037f Merge branch 'devel' 2022-10-20 10:31:23 +03:00
Eric
3f76d22d67 Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (398 of 398 strings)

Co-authored-by: Eric <hamburger1024@mailbox.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2022-10-20 10:05:11 +03:00
980988e684 Translated using Weblate (Russian)
Currently translated at 99.7% (397 of 398 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2022-10-20 10:05:11 +03:00
Allan Nordhøy
347811abb6 Translated using Weblate (Norwegian Bokmål)
Currently translated at 82.9% (330 of 398 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
Translation: Kotatsu/Strings
2022-10-20 10:05:11 +03:00
kuragehime
ccb8b0c8e7 Translated using Weblate (Japanese)
Currently translated at 100.0% (397 of 397 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2022-10-20 10:05:11 +03:00
Eric
18137ab48e Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (397 of 397 strings)

Co-authored-by: Eric <lessonaeamazon@paranoid.email>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2022-10-20 10:05:11 +03:00
Dpper
a9f435ae3d Translated using Weblate (Ukrainian)
Currently translated at 100.0% (397 of 397 strings)

Translated using Weblate (Ukrainian)

Currently translated at 100.0% (8 of 8 strings)

Co-authored-by: Dpper <ruslan20020401@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/uk/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-10-20 10:05:11 +03:00
0758cfef64 Translated using Weblate (Russian)
Currently translated at 99.7% (396 of 397 strings)

Co-authored-by: Zakhar Timoshenko <vp1984tanki@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2022-10-20 10:05:11 +03:00
06d1d56448 Update parsers 2022-10-20 10:04:52 +03:00
07c70eaccc Update strings in list mode bs 2022-10-20 09:25:20 +03:00
5ad6413952 Update parsers 2022-10-19 15:28:42 +03:00
Shippo
0b9d9ac7f2 Translated using Weblate (Arabic)
Currently translated at 14.9% (59 of 395 strings)

Translated using Weblate (Arabic)

Currently translated at 6.0% (24 of 395 strings)

Translated using Weblate (Arabic)

Currently translated at 25.0% (2 of 8 strings)

Added translation using Weblate (Arabic)

Added translation using Weblate (Arabic)

Co-authored-by: Shippo <shiposhouyou@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-10-19 15:28:31 +03:00
3ab87027ab Translated using Weblate (Russian)
Currently translated at 99.7% (394 of 395 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2022-10-19 15:28:31 +03:00
Eric
7545a774ba Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (395 of 395 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (392 of 392 strings)

Co-authored-by: Eric <hamburger1024@mailbox.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2022-10-19 15:28:31 +03:00
db16eb8e29 Translated using Weblate (Russian)
Currently translated at 99.7% (391 of 392 strings)

Co-authored-by: Zakhar Timoshenko <vp1984tanki@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2022-10-19 15:28:31 +03:00
Kyoya
e5a27a7c6f Translated using Weblate (Turkish)
Currently translated at 98.4% (386 of 392 strings)

Co-authored-by: Kyoya <thelol9181@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2022-10-19 15:28:31 +03:00
J. Lavoie
d26bc102d1 Translated using Weblate (French)
Currently translated at 100.0% (395 of 395 strings)

Translated using Weblate (French)

Currently translated at 100.0% (392 of 392 strings)

Translated using Weblate (German)

Currently translated at 94.1% (369 of 392 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2022-10-19 15:28:31 +03:00
Eduardo Malaspina
fc6a8afd93 Translated using Weblate (Spanish)
Currently translated at 99.2% (389 of 392 strings)

Co-authored-by: Eduardo Malaspina <vaio0@swismail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2022-10-19 15:28:31 +03:00
5a9d401446 Ui updates 2022-10-19 12:29:57 +03:00
77ac40b445 Option to clear new chapters counters 2022-10-19 11:26:45 +03:00
a29454f672 Incognito mode indicator 2022-10-19 11:05:11 +03:00
80ee7c8e54 Add installation id to acra reports 2022-10-19 10:26:12 +03:00
fb202f80a5 User-friendly message for HttpStatusException 2022-10-19 10:10:25 +03:00
45dbd5aa44 Fix crash on page loading 2022-10-16 10:41:53 +03:00
ee65251bf5 Update parsers 2022-10-16 10:38:23 +03:00
eaeb11f9ce Fix crash on page loading 2022-10-16 10:36:02 +03:00
2f74633abb Fix popup dark color text on dark dynamic theme 2022-10-16 00:06:57 +03:00
0f346dc725 Fix paddings 2022-10-16 00:05:57 +03:00
1b92848964 Wrong fourth screenshot... 2022-10-15 23:00:07 +03:00
3d91583585 Reduced screenshot resolution 2022-10-15 22:58:31 +03:00
f76d9fa3e4 Update phone screenshots 2022-10-15 22:51:12 +03:00
b00b2e406e Adjust app theme and add new empty states 2022-10-15 18:18:59 +03:00
74717e2b93 Handle offline mode in shelf 2022-10-14 17:04:04 +03:00
9b54ed6bc7 Show local section in shelf 2022-10-14 15:34:58 +03:00
7b36c64b34 Hide feed section if tracker is disabled 2022-10-14 15:02:50 +03:00
da09884136 Add updated manga to shelf 2022-10-14 14:49:53 +03:00
64aaf37556 Show sources names in onboarding 2022-10-14 13:36:33 +03:00
11104223eb Disable under-scaling in webtoon zoom 2022-10-14 13:14:04 +03:00
0c119bc137 Update parsers 2022-10-12 19:29:41 +03:00
5c058e626b Merge branch 'VietAnh14-webtoon_zoom' into devel 2022-10-12 12:52:50 +03:00
2005ae2bf3 Merge branch 'devel' of github.com:KotatsuApp/Kotatsu into VietAnh14-webtoon_zoom 2022-10-12 12:38:59 +03:00
d0650c7cf4 Refactor webtoon zoom option 2022-10-12 12:37:37 +03:00
Allan Nordhøy
2df4e6480a Translated using Weblate (Norwegian Bokmål)
Currently translated at 83.0% (324 of 390 strings)

Translated using Weblate (English)

Currently translated at 100.0% (390 of 390 strings)

Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/nb_NO/
Translation: Kotatsu/Strings
2022-10-12 12:28:54 +03:00
J. Lavoie
017a1686dc Translated using Weblate (French)
Currently translated at 100.0% (390 of 390 strings)

Translated using Weblate (German)

Currently translated at 94.6% (369 of 390 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2022-10-12 12:28:54 +03:00
Cường Bá
279dc03695 Translated using Weblate (Vietnamese)
Currently translated at 62.5% (5 of 8 strings)

Added translation using Weblate (Vietnamese)

Co-authored-by: Cường Bá <cuongba956@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/vi/
Translation: Kotatsu/plurals
2022-10-12 12:28:54 +03:00
kuragehime
c8c482f692 Translated using Weblate (Japanese)
Currently translated at 100.0% (390 of 390 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2022-10-12 12:28:54 +03:00
Eric
02a0e3ebcd Translated using Weblate (Chinese (Simplified))
Currently translated at 99.4% (388 of 390 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (390 of 390 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (389 of 389 strings)

Co-authored-by: Eric <hamburger1024@mailbox.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2022-10-12 12:28:54 +03:00
Daniel Rozario
fc0b3f3b38 Translated using Weblate (Bengali)
Currently translated at 2.5% (10 of 389 strings)

Translated using Weblate (Bengali)

Currently translated at 37.5% (3 of 8 strings)

Added translation using Weblate (Bengali)

Added translation using Weblate (Bengali)

Co-authored-by: Daniel Rozario <rozario@tuta.io>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/bn/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/bn/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2022-10-12 12:28:54 +03:00
vianh
2925900214 Add disable webtoon zoom setting 2022-10-11 18:13:46 +07:00
vianh
eae370e41c Adjust zoom focus point 2022-10-07 22:17:53 +07:00
vianh
d0338a604a Minor adjustment for webtoon scale 2022-10-07 16:15:53 +07:00
vianh
e22b98b476 Support zoom for webtoon mode 2022-10-07 11:13:30 +07:00
4d838d290d Update dependencies 2022-10-03 08:41:32 +03:00
048efdf59f Fix crash on slider 2022-10-03 08:07:25 +03:00
65dbc6b8e5 Fix crash on slider 2022-10-03 08:02:49 +03:00
af2adeba13 Fix opening fingerprint dialog 2022-10-01 12:19:36 +03:00
93c6bec452 Fix widgets colors 2022-10-01 10:34:25 +03:00
c944044465 Update version 2022-09-22 17:38:08 +03:00
8a63ca2310 Fix coroutines cancellation 2022-09-22 17:33:40 +03:00
12e5e3b35e Update gitignore 2022-09-22 16:53:27 +03:00
553a85ef86 Widget theme fix #225 2022-09-22 16:49:38 +03:00
de7012cabf Change acra sender to http 2022-09-15 08:36:20 +03:00
182 changed files with 3334 additions and 1230 deletions

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +1,2 @@
ko_fi: xtimms
custom: ["https://yoomoney.ru/to/410012543938752"]
ko_fi: koitharu

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@
/.idea/modules.xml
/.idea/misc.xml
/.idea/discord.xml
/.idea/compiler.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml

6
.idea/compiler.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="11" />
</component>
</project>

2
.idea/kotlinc.xml generated
View File

@@ -4,6 +4,6 @@
<option name="jvmTarget" value="1.8" />
</component>
<component name="KotlinJpsPluginSettings">
<option name="version" value="1.7.10" />
<option name="version" value="1.7.20" />
</component>
</project>

View File

@@ -26,7 +26,7 @@ Download APK directly from GitHub:
* Notifications about new chapters with updates feed
* Shikimori integration (manga tracking)
* Password/fingerprint protect access to the app
* History and favourites synchronization across devices (coming soon)
* History and favourites [synchronization](https://github.com/KotatsuApp/kotatsu-syncserver) across devices
### Screenshots

View File

@@ -15,8 +15,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 33
versionCode 499
versionName '4.0-rc1'
versionCode 507
versionName '4.1'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -83,15 +83,15 @@ afterEvaluate {
}
}
dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:b1990c7918') {
implementation('com.github.KotatsuApp:kotatsu-parsers:9ee1c21a67') {
exclude group: 'org.json', module: 'json'
}
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.activity:activity-ktx:1.6.0'
implementation 'androidx.fragment:fragment-ktx:1.5.3'
implementation 'androidx.activity:activity-ktx:1.6.1'
implementation 'androidx.fragment:fragment-ktx:1.5.5'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-service:2.5.1'
@@ -103,7 +103,7 @@ dependencies {
implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.work:work-runtime-ktx:2.7.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.7.0-rc01'
implementation 'com.google.android.material:material:1.7.0'
//noinspection LifecycleAnnotationProcessorWithJava8
kapt 'androidx.lifecycle:lifecycle-compiler:2.5.1'
@@ -118,35 +118,35 @@ 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.44"
kapt "com.google.dagger:hilt-compiler:2.44"
implementation 'com.google.dagger:hilt-android:2.44.2'
kapt 'com.google.dagger:hilt-compiler:2.44.2'
implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.2.1'
implementation 'io.coil-kt:coil-svg:2.2.1'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:0ff0278f0f'
implementation 'io.coil-kt:coil-base:2.2.2'
implementation 'io.coil-kt:coil-svg:2.2.2'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:95e6c188c6'
implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'ch.acra:acra-http:5.9.6'
implementation 'ch.acra:acra-dialog:5.9.6'
implementation 'ch.acra:acra-http:5.9.7'
implementation 'ch.acra:acra-dialog:5.9.7'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20220320'
testImplementation 'org.json:json:20220924'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
androidTestImplementation 'androidx.test:runner:1.4.0'
androidTestImplementation 'androidx.test:rules:1.4.0'
androidTestImplementation 'androidx.test:core-ktx:1.4.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.3'
androidTestImplementation 'androidx.test:runner:1.5.1'
androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.4'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
androidTestImplementation 'androidx.room:room-testing:2.4.3'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.14.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.44'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.44'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.44.2'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.44.2'
}

View File

@@ -28,6 +28,8 @@
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:largeHeap="true"
android:localeConfig="@xml/locales"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
@@ -68,6 +70,9 @@
<activity
android:name="org.koitharu.kotatsu.history.ui.HistoryActivity"
android:label="@string/history" />
<activity
android:name="org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity"
android:label="@string/updates" />
<activity
android:name="org.koitharu.kotatsu.favourites.ui.FavouritesActivity"
android:label="@string/favourites" />
@@ -105,8 +110,7 @@
<activity
android:name="org.koitharu.kotatsu.widget.shelf.ShelfConfigActivity"
android:exported="true"
android:label="@string/manga_shelf"
android:theme="@style/Theme.Kotatsu.DialogWhenLarge">
android:label="@string/manga_shelf">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
@@ -124,18 +128,18 @@
<activity
android:name="org.koitharu.kotatsu.download.ui.DownloadsActivity"
android:label="@string/downloads"
android:launchMode="singleTop"
android:theme="@style/Theme.Kotatsu.DialogWhenLarge" />
android:launchMode="singleTop" />
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" />
<activity
android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity"
android:theme="@style/Theme.Kotatsu.DialogWhenLarge" />
<activity android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity" />
<activity
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity"
android:label="@string/sync" />
<activity
android:name="org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity"
android:label="@string/color_correction" />
<activity
android:name="org.koitharu.kotatsu.shelf.ui.config.ShelfSettingsActivity"
android:label="@string/settings" />
<service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"

View File

@@ -10,7 +10,6 @@ import androidx.hilt.work.HiltWorkerFactory
import androidx.room.InvalidationTracker
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.acra.ReportField
@@ -25,6 +24,7 @@ import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import javax.inject.Inject
@HiltAndroidApp
class KotatsuApp : Application(), Configuration.Provider {
@@ -72,6 +72,7 @@ class KotatsuApp : Application(), Configuration.Provider {
}
reportContent = listOf(
ReportField.PACKAGE_NAME,
ReportField.INSTALLATION_ID,
ReportField.APP_VERSION_CODE,
ReportField.APP_VERSION_NAME,
ReportField.ANDROID_VERSION,

View File

@@ -53,7 +53,6 @@ abstract class BaseActivity<B : ViewBinding> :
EntryPointAccessors.fromApplication(this, BaseActivityEntryPoint::class.java).inject(this)
val isAmoled = settings.isAmoledTheme
val isDynamic = settings.isDynamicTheme
// TODO support DialogWhenLarge theme
when {
isAmoled && isDynamic -> setTheme(R.style.Theme_Kotatsu_Monet_Amoled)
isAmoled -> setTheme(R.style.Theme_Kotatsu_Amoled)

View File

@@ -0,0 +1,22 @@
package org.koitharu.kotatsu.base.ui
import android.app.Activity
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle
interface DefaultActivityLifecycleCallbacks : ActivityLifecycleCallbacks {
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) = Unit
}

View File

@@ -6,7 +6,7 @@ import android.view.View.OnLongClickListener
import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder
class AdapterDelegateClickListenerAdapter<I>(
private val adapterDelegate: AdapterDelegateViewBindingViewHolder<I, *>,
private val adapterDelegate: AdapterDelegateViewBindingViewHolder<out I, *>,
private val clickListener: OnListItemClickListener<I>,
) : OnClickListener, OnLongClickListener {
@@ -17,4 +17,4 @@ class AdapterDelegateClickListenerAdapter<I>(
override fun onLongClick(v: View): Boolean {
return clickListener.onItemLongClick(adapterDelegate.item, v)
}
}
}

View File

@@ -2,20 +2,20 @@ package org.koitharu.kotatsu.base.ui.list
import android.app.Activity
import android.os.Bundle
import android.util.ArrayMap
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.collection.ArrayMap
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryOwner
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.Dispatchers
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
import kotlin.coroutines.EmptyCoroutineContext
private const val PROVIDER_NAME = "selection_decoration_sectioned"

View File

@@ -1,14 +1,14 @@
package org.koitharu.kotatsu.base.ui.util
import android.app.Activity
import android.app.Application.ActivityLifecycleCallbacks
import android.os.Bundle
import java.util.*
import org.koitharu.kotatsu.base.ui.DefaultActivityLifecycleCallbacks
import java.util.WeakHashMap
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ActivityRecreationHandle @Inject constructor() : ActivityLifecycleCallbacks {
class ActivityRecreationHandle @Inject constructor() : DefaultActivityLifecycleCallbacks {
private val activities = WeakHashMap<Activity, Unit>()
@@ -16,16 +16,6 @@ class ActivityRecreationHandle @Inject constructor() : ActivityLifecycleCallback
activities[activity] = Unit
}
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) {
activities.remove(activity)
}

View File

@@ -18,8 +18,6 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.ElementsIntoSet
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import okhttp3.CookieJar
import okhttp3.OkHttpClient
@@ -27,6 +25,7 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.base.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.network.*
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.os.ShortcutsUpdater
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -40,9 +39,13 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import org.koitharu.kotatsu.settings.backup.BackupObserver
import org.koitharu.kotatsu.sync.domain.SyncController
import org.koitharu.kotatsu.utils.IncognitoModeIndicator
import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.isLowRamDevice
import org.koitharu.kotatsu.utils.image.CoilImageGetter
import org.koitharu.kotatsu.widget.WidgetUpdater
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@@ -80,6 +83,12 @@ interface AppModule {
}.build()
}
@Provides
@Singleton
fun provideNetworkState(
@ApplicationContext context: Context
) = NetworkState(context.connectivityManager)
@Provides
@Singleton
fun provideMangaDatabase(
@@ -152,9 +161,11 @@ interface AppModule {
fun provideActivityLifecycleCallbacks(
appProtectHelper: AppProtectHelper,
activityRecreationHandle: ActivityRecreationHandle,
incognitoModeIndicator: IncognitoModeIndicator,
): Set<@JvmSuppressWildcards Application.ActivityLifecycleCallbacks> = arraySetOf(
appProtectHelper,
activityRecreationHandle,
incognitoModeIndicator,
)
}
}

View File

@@ -14,11 +14,11 @@ abstract class MangaDao {
abstract suspend fun find(id: Long): MangaWithTags?
@Transaction
@Query("SELECT * FROM manga WHERE title LIKE :query OR alt_title LIKE :query LIMIT :limit")
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
@Transaction
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND source = :source LIMIT :limit")
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND source = :source AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
abstract suspend fun searchByTitle(query: String, source: String, limit: Int): List<MangaWithTags>
@Insert(onConflict = OnConflictStrategy.IGNORE)
@@ -47,4 +47,4 @@ abstract class MangaDao {
}
}
}
}
}

View File

@@ -11,7 +11,7 @@ import org.koitharu.kotatsu.utils.ext.longHashCode
fun TagEntity.toMangaTag() = MangaTag(
key = this.key,
title = this.title.toTitleCase(),
source = MangaSource(this.source) ?: MangaSource.DUMMY,
source = MangaSource(this.source),
)
fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
@@ -28,7 +28,7 @@ fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
coverUrl = this.coverUrl,
largeCoverUrl = this.largeCoverUrl,
author = this.author,
source = MangaSource(this.source) ?: MangaSource.DUMMY,
source = MangaSource(this.source),
tags = tags,
)

View File

@@ -1,14 +1,11 @@
package org.koitharu.kotatsu.core.exceptions.resolve
import android.util.ArrayMap
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.ActivityResultLauncher
import androidx.annotation.StringRes
import androidx.collection.ArrayMap
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlinx.coroutines.suspendCancellableCoroutine
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.browser.BrowserActivity
@@ -20,6 +17,9 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import org.koitharu.kotatsu.utils.TaggedActivityResult
import org.koitharu.kotatsu.utils.isSuccess
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class ExceptionResolver private constructor(
private val activity: FragmentActivity?,
@@ -49,6 +49,7 @@ class ExceptionResolver private constructor(
openInBrowser(e.url)
false
}
else -> false
}

View File

@@ -1,18 +1,17 @@
package org.koitharu.kotatsu.core.model
import java.util.*
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toTitleCase
import java.util.Locale
fun MangaSource.getLocaleTitle(): String? {
val lc = Locale(locale ?: return null)
return lc.getDisplayLanguage(lc).toTitleCase(lc)
}
@Suppress("FunctionName")
fun MangaSource(name: String): MangaSource? {
fun MangaSource(name: String): MangaSource {
MangaSource.values().forEach {
if (it.name == name) return it
}
return null
}
return MangaSource.DUMMY
}

View File

@@ -0,0 +1,46 @@
package org.koitharu.kotatsu.core.os
import android.net.ConnectivityManager
import android.net.ConnectivityManager.NetworkCallback
import android.net.Network
import android.net.NetworkRequest
import kotlinx.coroutines.flow.first
import org.koitharu.kotatsu.utils.MediatorStateFlow
import org.koitharu.kotatsu.utils.ext.isNetworkAvailable
class NetworkState(
private val connectivityManager: ConnectivityManager,
) : MediatorStateFlow<Boolean>(connectivityManager.isNetworkAvailable) {
private val callback = NetworkCallbackImpl()
override fun onActive() {
invalidate()
val request = NetworkRequest.Builder().build()
connectivityManager.registerNetworkCallback(request, callback)
}
override fun onInactive() {
connectivityManager.unregisterNetworkCallback(callback)
}
suspend fun awaitForConnection() {
if (value) {
return
}
first { it }
}
private fun invalidate() {
publishValue(connectivityManager.isNetworkAvailable)
}
private inner class NetworkCallbackImpl : NetworkCallback() {
override fun onAvailable(network: Network) = invalidate()
override fun onLost(network: Network) = invalidate()
override fun onUnavailable() = invalidate()
}
}

View File

@@ -14,7 +14,6 @@ import coil.network.HttpException
import coil.request.Options
import coil.size.Size
import coil.size.pxOrElse
import java.net.HttpURLConnection
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
@@ -27,6 +26,7 @@ import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.local.data.CacheDir
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import java.net.HttpURLConnection
private const val FALLBACK_SIZE = 9999 // largest icon
@@ -150,7 +150,7 @@ class FaviconFetcher(
override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
return if (data.scheme == URI_SCHEME_FAVICON) {
val mangaSource = MangaSource(data.schemeSpecificPart) ?: return null
val mangaSource = MangaSource(data.schemeSpecificPart)
FaviconFetcher(okHttpClient, diskCache, mangaSource, options, mangaRepositoryFactory)
} else {
null

View File

@@ -1,6 +0,0 @@
package org.koitharu.kotatsu.core.prefs
enum class AppSection {
LOCAL, FAVOURITES, HISTORY, FEED, SUGGESTIONS
}

View File

@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.network.DoHProvider
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.shelf.domain.ShelfSection
import org.koitharu.kotatsu.utils.ext.getEnumValue
import org.koitharu.kotatsu.utils.ext.observe
import org.koitharu.kotatsu.utils.ext.putEnumValue
@@ -44,14 +45,26 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val remoteMangaSources: Set<MangaSource>
get() = Collections.unmodifiableSet(remoteSources)
var shelfSections: List<ShelfSection>
get() {
val raw = prefs.getString(KEY_SHELF_SECTIONS, null)
val values = enumValues<ShelfSection>()
if (raw.isNullOrEmpty()) {
return values.toList()
}
return raw.split('|')
.mapNotNull { values.getOrNull(it.toIntOrNull() ?: -1) }
.distinct()
}
set(value) {
val raw = value.joinToString("|") { it.ordinal.toString() }
prefs.edit { putString(KEY_SHELF_SECTIONS, raw) }
}
var listMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE, value) }
var defaultSection: AppSection
get() = prefs.getEnumValue(KEY_APP_SECTION, AppSection.HISTORY)
set(value) = prefs.edit { putEnumValue(KEY_APP_SECTION, value) }
val theme: Int
get() = prefs.getString(KEY_THEME, null)?.toIntOrNull() ?: AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
@@ -218,6 +231,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getEnumValue(KEY_LOCAL_LIST_ORDER, SortOrder.NEWEST)
set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) }
val isWebtoonZoomEnable: Boolean
get() = prefs.getBoolean(KEY_WEBTOON_ZOOM, true)
fun isPagesPreloadAllowed(cm: ConnectivityManager): Boolean {
return when (prefs.getString(KEY_PAGES_PRELOAD, null)?.toIntOrNull()) {
NETWORK_ALWAYS -> true
@@ -337,6 +353,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SHORTCUTS = "dynamic_shortcuts"
const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
const val KEY_LOCAL_LIST_ORDER = "local_order"
const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
const val KEY_SHELF_SECTIONS = "shelf_sections_2"
// About
const val KEY_APP_UPDATE = "app_update"

View File

@@ -115,4 +115,4 @@ class ZipOutput(
closeEntry()
return true
}
}
}

View File

@@ -34,7 +34,7 @@ fun chapterListItemAD(
when (item.status) {
FLAG_UNREAD -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_default)
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorSecondaryInverse))
binding.textViewNumber.setTextColor(context.getThemeColor(com.google.android.material.R.attr.colorOnTertiary))
}
FLAG_CURRENT -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_accent)
@@ -53,4 +53,4 @@ fun chapterListItemAD(
binding.imageViewDownloaded.isVisible = item.hasFlag(FLAG_DOWNLOADED)
binding.imageViewNew.isVisible = item.hasFlag(FLAG_NEW)
}
}
}

View File

@@ -17,7 +17,6 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
@@ -36,6 +35,7 @@ import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.referer
@@ -219,10 +219,8 @@ class DownloadManager @AssistedInject constructor(
val call = okHttp.newCall(request)
val file = File(destination, tempFileName)
val response = call.clone().await()
runInterruptible(Dispatchers.IO) {
file.outputStream().use { out ->
checkNotNull(response.body).byteStream().copyTo(out)
}
file.outputStream().use { out ->
checkNotNull(response.body).byteStream().copyToSuspending(out)
}
return file
}

View File

@@ -2,8 +2,10 @@ package org.koitharu.kotatsu.explore.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
@@ -11,11 +13,12 @@ import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity
import org.koitharu.kotatsu.databinding.FragmentExploreBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -31,6 +34,7 @@ import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import javax.inject.Inject
@AndroidEntryPoint
class ExploreFragment :
@@ -67,6 +71,7 @@ class ExploreFragment :
}
viewModel.onError.observe(viewLifecycleOwner, ::onError)
viewModel.onOpenManga.observe(viewLifecycleOwner, ::onOpenManga)
viewModel.onActionDone.observe(viewLifecycleOwner, ::onActionDone)
}
override fun onDestroyView() {
@@ -95,6 +100,7 @@ class ExploreFragment :
viewModel.openRandom()
return
}
else -> return
}
startActivity(intent)
@@ -105,6 +111,14 @@ class ExploreFragment :
startActivity(intent)
}
override fun onItemLongClick(item: ExploreItem.Source, view: View): Boolean {
val menu = PopupMenu(view.context, view)
menu.inflate(R.menu.popup_source)
menu.setOnMenuItemClickListener(SourceMenuListener(item))
menu.show()
return true
}
override fun onRetryClick(error: Throwable) = Unit
override fun onEmptyActionClick() = onManageClick(requireView())
@@ -124,6 +138,37 @@ class ExploreFragment :
startActivity(intent)
}
private fun onActionDone(action: ReversibleAction) {
val handle = action.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
val snackbar = Snackbar.make(binding.recyclerView, action.stringResId, length)
if (handle != null) {
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
}
snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav
snackbar.show()
}
private inner class SourceMenuListener(
private val sourceItem: ExploreItem.Source,
) : PopupMenu.OnMenuItemClickListener {
override fun onMenuItemClick(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_settings -> {
startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), sourceItem.source))
}
R.id.action_hide -> {
viewModel.hideSource(sourceItem.source)
}
else -> return false
}
return true
}
}
companion object {
fun newInstance() = ExploreFragment()

View File

@@ -4,11 +4,17 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.asFlow
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.ReversibleHandle
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.explore.ui.model.ExploreItem
@@ -16,6 +22,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import javax.inject.Inject
@HiltViewModel
class ExploreViewModel @Inject constructor(
@@ -24,6 +31,7 @@ class ExploreViewModel @Inject constructor(
) : BaseViewModel() {
val onOpenManga = SingleLiveEvent<Manga>()
val onActionDone = SingleLiveEvent<ReversibleAction>()
val content: LiveData<List<ExploreItem>> = isLoading.asFlow().flatMapLatest { loading ->
if (loading) {
@@ -40,6 +48,16 @@ class ExploreViewModel @Inject constructor(
}
}
fun hideSource(source: MangaSource) {
launchJob(Dispatchers.Default) {
settings.hiddenSources += source.name
val rollback = ReversibleHandle {
settings.hiddenSources -= source.name
}
onActionDone.postCall(ReversibleAction(R.string.source_disabled, rollback))
}
}
private fun createContentFlow() = settings.observe()
.filter {
it == AppSettings.KEY_SOURCES_HIDDEN ||
@@ -61,7 +79,7 @@ class ExploreViewModel @Inject constructor(
sources.mapTo(result) { ExploreItem.Source(it) }
} else {
result += ExploreItem.EmptyHint(
icon = R.drawable.ic_empty_search,
icon = R.drawable.ic_empty_common,
textPrimary = R.string.no_manga_sources,
textSecondary = R.string.no_manga_sources_text,
actionStringRes = R.string.manage,

View File

@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.explore.ui.model
import android.net.Uri
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.koitharu.kotatsu.list.ui.model.EmptyState
@@ -73,6 +72,7 @@ sealed interface ExploreItem : ListModel {
}
}
@Deprecated("")
class EmptyHint(
@DrawableRes icon: Int,
@StringRes textPrimary: Int,
@@ -81,4 +81,4 @@ sealed interface ExploreItem : ListModel {
) : EmptyState(icon, textPrimary, textSecondary, actionStringRes), ExploreItem
object Loading : ExploreItem
}
}

View File

@@ -6,24 +6,23 @@ import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.button.MaterialButtonToggleGroup
import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.base.ui.widgets.CheckableButtonGroup
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.databinding.DialogListModeBinding
import org.koitharu.kotatsu.utils.ext.setValueRounded
import org.koitharu.kotatsu.utils.progress.IntPercentLabelFormatter
import javax.inject.Inject
@AndroidEntryPoint
class ListModeSelectDialog :
AlertDialogFragment<DialogListModeBinding>(),
CheckableButtonGroup.OnCheckedChangeListener,
Slider.OnChangeListener {
class ListModeBottomSheet :
BaseBottomSheet<DialogListModeBinding>(),
Slider.OnChangeListener,
MaterialButtonToggleGroup.OnButtonCheckedListener {
@Inject
lateinit var settings: AppSettings
@@ -33,13 +32,6 @@ class ListModeSelectDialog :
container: ViewGroup?,
) = DialogListModeBinding.inflate(inflater, container, false)
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
return super.onBuildDialog(builder)
.setTitle(R.string.list_mode)
.setPositiveButton(R.string.done, null)
.setCancelable(true)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val mode = settings.listMode
@@ -53,10 +45,10 @@ class ListModeSelectDialog :
binding.sliderGrid.setValueRounded(settings.gridSize.toFloat())
binding.sliderGrid.addOnChangeListener(this)
binding.checkableGroup.onCheckedChangeListener = this
binding.checkableGroup.addOnButtonCheckedListener(this)
}
override fun onCheckedChanged(group: CheckableButtonGroup, checkedId: Int) {
override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) {
val mode = when (checkedId) {
R.id.button_list -> ListMode.LIST
R.id.button_list_detailed -> ListMode.DETAILED_LIST
@@ -78,6 +70,6 @@ class ListModeSelectDialog :
private const val TAG = "ListModeSelectDialog"
fun show(fm: FragmentManager) = ListModeSelectDialog().show(fm, TAG)
fun show(fm: FragmentManager) = ListModeBottomSheet().show(fm, TAG)
}
}

View File

@@ -17,9 +17,10 @@ class MangaListMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
R.id.action_list_mode -> {
ListModeSelectDialog.show(fragment.childFragmentManager)
ListModeBottomSheet.show(fragment.childFragmentManager)
true
}
else -> false
}
}
}

View File

@@ -0,0 +1,23 @@
package org.koitharu.kotatsu.list.ui.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.databinding.ItemEmptyCardBinding
import org.koitharu.kotatsu.list.ui.model.EmptyHint
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.utils.ext.setTextAndVisible
fun emptyHintAD(
listener: ListStateHolderListener,
) = adapterDelegateViewBinding<EmptyHint, ListModel, ItemEmptyCardBinding>(
{ inflater, parent -> ItemEmptyCardBinding.inflate(inflater, parent, false) },
) {
binding.buttonRetry.setOnClickListener { listener.onEmptyActionClick() }
bind {
binding.icon.setImageResource(item.icon)
binding.textPrimary.setText(item.textPrimary)
binding.textSecondary.setTextAndVisible(item.textSecondary)
binding.buttonRetry.setTextAndVisible(item.actionStringRes)
}
}

View File

@@ -0,0 +1,14 @@
package org.koitharu.kotatsu.list.ui.model
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
class EmptyHint(
@DrawableRes icon: Int,
@StringRes textPrimary: Int,
@StringRes textSecondary: Int,
@StringRes actionStringRes: Int,
) : EmptyState(icon, textPrimary, textSecondary, actionStringRes) {
fun toState() = EmptyState(icon, textPrimary, textSecondary, actionStringRes)
}

View File

@@ -3,22 +3,30 @@ package org.koitharu.kotatsu.local.data
import android.content.Context
import com.tomclaw.cache.DiskLruCache
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.subdir
import org.koitharu.kotatsu.utils.ext.takeIfReadable
import org.koitharu.kotatsu.utils.ext.takeIfWriteable
import java.io.File
import java.io.InputStream
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.utils.FileSize
import org.koitharu.kotatsu.utils.ext.longHashCode
import org.koitharu.kotatsu.utils.ext.subdir
import org.koitharu.kotatsu.utils.ext.takeIfReadable
@Singleton
class PagesCache @Inject constructor(@ApplicationContext context: Context) {
private val cacheDir = context.externalCacheDir ?: context.cacheDir
private val cacheDir = checkNotNull(findSuitableDir(context)) {
val dirs = (context.externalCacheDirs + context.cacheDir).joinToString(";") {
it.absolutePath
}
"Cannot find any suitable directory for PagesCache: [$dirs]"
}
private val lruCache = createDiskLruCacheSafe(
dir = cacheDir.subdir(CacheDir.PAGES.dir),
dir = cacheDir,
size = FileSize.MEGABYTES.convert(200, FileSize.BYTES),
)
@@ -26,42 +34,15 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
return lruCache.get(url)?.takeIfReadable()
}
fun put(url: String, inputStream: InputStream): File {
val file = File(cacheDir, url.longHashCode().toString())
file.outputStream().use { out ->
inputStream.copyTo(out)
}
val res = lruCache.put(url, file)
file.delete()
return res
}
fun put(
url: String,
inputStream: InputStream,
contentLength: Long,
progress: MutableStateFlow<Float>,
): File {
val file = File(cacheDir, url.longHashCode().toString())
file.outputStream().use { out ->
var bytesCopied: Long = 0
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = inputStream.read(buffer)
while (bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
publishProgress(contentLength, bytesCopied, progress)
bytes = inputStream.read(buffer)
suspend fun put(url: String, inputStream: InputStream): File = withContext(Dispatchers.IO) {
val file = File(cacheDir.parentFile, url.longHashCode().toString())
try {
file.outputStream().use { out ->
inputStream.copyToSuspending(out)
}
}
val res = lruCache.put(url, file)
file.delete()
return res
}
private fun publishProgress(contentLength: Long, bytesCopied: Long, progress: MutableStateFlow<Float>) {
if (contentLength > 0) {
progress.value = (bytesCopied.toDouble() / contentLength.toDouble()).toFloat()
lruCache.put(url, file)
} finally {
file.delete()
}
}
}
@@ -75,3 +56,10 @@ private fun createDiskLruCacheSafe(dir: File, size: Long): DiskLruCache {
DiskLruCache.create(dir, size)
}
}
private fun findSuitableDir(context: Context): File? {
val dirs = context.externalCacheDirs + context.cacheDir
return dirs.firstNotNullOfOrNull {
it.subdir(CacheDir.PAGES.dir).takeIfWriteable()
}
}

View File

@@ -4,7 +4,6 @@ import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.documentfile.provider.DocumentFile
import java.io.File
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.local.data.LocalStorageManager
@@ -14,8 +13,11 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
import org.koitharu.kotatsu.utils.AlphanumComparator
import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.longOf
import java.io.File
// TODO: Add support for chapters in cbz
// https://github.com/KotatsuApp/Kotatsu/issues/31
@@ -57,11 +59,12 @@ class DirMangaImporter(
private suspend fun addPages(output: CbzMangaOutput, root: DocumentFile, path: String, state: State) {
var number = 0
for (file in root.listFiles()) {
for (file in root.listFiles().sortedWith(compareBy(AlphanumComparator()) { it.name.orEmpty() })) {
when {
file.isDirectory -> {
addPages(output, file, path + "/" + file.name, state)
}
file.isFile -> {
val tempFile = file.asTempFile()
if (!state.hasCover) {
@@ -86,7 +89,7 @@ class DirMangaImporter(
"Cannot open input stream for $uri"
}.use { input ->
file.outputStream().use { output ->
input.copyTo(output)
input.copyToSuspending(output)
}
}
return file

View File

@@ -1,8 +1,6 @@
package org.koitharu.kotatsu.local.domain.importer
import android.net.Uri
import java.io.File
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
@@ -11,7 +9,10 @@ import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.copyToSuspending
import org.koitharu.kotatsu.utils.ext.resolveName
import java.io.File
import java.io.IOException
class ZipMangaImporter(
storageManager: LocalStorageManager,
@@ -27,10 +28,10 @@ class ZipMangaImporter(
}
val dest = File(getOutputDir(), name)
runInterruptible {
contentResolver.openInputStream(uri)?.use { source ->
dest.outputStream().use { output ->
source.copyTo(output)
}
contentResolver.openInputStream(uri)
}?.use { source ->
dest.outputStream().use { output ->
source.copyToSuspending(output)
}
} ?: throw IOException("Cannot open input stream: $uri")
localMangaRepository.getFromFile(dest)

View File

@@ -15,15 +15,23 @@ import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.core.graphics.Insets
import androidx.core.util.size
import androidx.core.view.*
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction
import androidx.fragment.app.commit
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.whenResumed
import androidx.transition.TransitionManager
import com.google.android.material.R as materialR
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.appbar.AppBarLayout.LayoutParams.*
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_ENTER_ALWAYS
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL
import com.google.android.material.appbar.AppBarLayout.LayoutParams.SCROLL_FLAG_SNAP
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
@@ -34,7 +42,6 @@ import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.base.ui.widgets.SlidingBottomNavigationView
import org.koitharu.kotatsu.databinding.ActivityMainBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.shelf.ui.ShelfFragment
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.model.Manga
@@ -48,11 +55,18 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.settings.newsources.NewSourcesDialogFragment
import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment
import org.koitharu.kotatsu.shelf.ui.ShelfFragment
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.VoiceInputContract
import org.koitharu.kotatsu.utils.ext.*
import kotlin.text.Typography.dagger
import org.koitharu.kotatsu.utils.ext.drawableEnd
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.hideKeyboard
import org.koitharu.kotatsu.utils.ext.resolve
import org.koitharu.kotatsu.utils.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.utils.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.utils.ext.tryLaunch
import com.google.android.material.R as materialR
private const val TAG_SEARCH = "search"
@@ -116,6 +130,8 @@ class MainActivity :
viewModel.isLoading.observe(this, this::onLoadingStateChanged)
viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged)
viewModel.counters.observe(this, ::onCountersChanged)
viewModel.isFeedAvailable.observe(this, ::onFeedAvailabilityChanged)
searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged)
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@@ -139,11 +155,7 @@ class MainActivity :
}
override fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) {
if (fragment is ShelfFragment) {
binding.fab?.show()
} else {
binding.fab?.hide()
}
adjustFabVisibility(topFragment = fragment)
if (fromUser) {
binding.appbar.setExpanded(true)
}
@@ -261,6 +273,20 @@ class MainActivity :
}
}
private fun onFeedAvailabilityChanged(isFeedAvailable: Boolean) {
navigationDelegate.setItemVisibility(R.id.nav_feed, isFeedAvailable)
}
private fun onIncognitoModeChanged(isIncognito: Boolean) {
var options = binding.searchView.imeOptions
options = if (isIncognito) {
options or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING
} else {
options and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv()
}
binding.searchView.imeOptions = options
}
private fun onLoadingStateChanged(isLoading: Boolean) {
binding.fab?.isEnabled = !isLoading
}
@@ -296,8 +322,13 @@ class MainActivity :
private fun onFirstStart() {
lifecycleScope.launch(Dispatchers.Main) { // not a default `Main.immediate` dispatcher
when {
!settings.isSourcesSelected -> OnboardDialogFragment.showWelcome(supportFragmentManager)
settings.newSources.isNotEmpty() -> NewSourcesDialogFragment.show(supportFragmentManager)
!settings.isSourcesSelected -> whenResumed {
OnboardDialogFragment.showWelcome(supportFragmentManager)
}
settings.newSources.isNotEmpty() -> whenResumed {
NewSourcesDialogFragment.show(supportFragmentManager)
}
}
withContext(Dispatchers.Default) {
TrackWorker.setup(applicationContext)
@@ -312,18 +343,18 @@ class MainActivity :
topFragment: Fragment? = navigationDelegate.primaryFragment,
isSearchOpened: Boolean = isSearchOpened(),
) {
val fab = binding.fab
val fab = binding.fab ?: return
if (
isResumeEnabled &&
!actionModeDelegate.isActionModeStarted &&
!isSearchOpened &&
topFragment is ShelfFragment
) {
if (fab?.isVisible == false) {
if (!fab.isVisible) {
fab.show()
}
} else {
if (fab?.isVisible == true) {
if (fab.isVisible) {
fab.hide()
}
}

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.main.ui
import android.os.Bundle
import android.view.MenuItem
import androidx.annotation.IdRes
import androidx.core.view.iterator
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction
@@ -10,10 +11,10 @@ import com.google.android.material.navigation.NavigationBarView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.util.RecyclerViewOwner
import org.koitharu.kotatsu.explore.ui.ExploreFragment
import org.koitharu.kotatsu.shelf.ui.ShelfFragment
import org.koitharu.kotatsu.settings.tools.ToolsFragment
import org.koitharu.kotatsu.tracker.ui.FeedFragment
import java.util.*
import org.koitharu.kotatsu.shelf.ui.ShelfFragment
import org.koitharu.kotatsu.tracker.ui.feed.FeedFragment
import java.util.LinkedList
private const val TAG_PRIMARY = "primary"
@@ -62,6 +63,14 @@ class MainNavigationDelegate(
}
}
fun setItemVisibility(@IdRes itemId: Int, isVisible: Boolean) {
val item = navBar.menu.findItem(itemId) ?: return
item.isVisible = isVisible
if (item.isChecked && !isVisible) {
navBar.selectedItemId = firstItem()?.itemId ?: return
}
}
fun addOnFragmentChangedListener(listener: OnFragmentChangedListener) {
listeners.add(listener)
}
@@ -96,6 +105,14 @@ class MainNavigationDelegate(
listeners.forEach { it.onFragmentChanged(fragment, fromUser) }
}
private fun firstItem(): MenuItem? {
val menu = navBar.menu
for (item in menu) {
if (item.isVisible) return item
}
return null
}
interface OnFragmentChangedListener {
fun onFragmentChanged(fragment: Fragment, fromUser: Boolean)

View File

@@ -4,7 +4,6 @@ import android.util.SparseIntArray
import androidx.core.util.set
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
@@ -12,6 +11,8 @@ import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.sync.domain.SyncController
@@ -19,6 +20,7 @@ import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(
@@ -27,6 +29,7 @@ class MainViewModel @Inject constructor(
private val trackingRepository: TrackingRepository,
syncController: SyncController,
database: MangaDatabase,
private val settings: AppSettings,
) : BaseViewModel() {
val onOpenReader = SingleLiveEvent<Manga>()
@@ -35,6 +38,12 @@ class MainViewModel @Inject constructor(
.observeHasItems()
.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, false)
val isFeedAvailable = settings.observeAsLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default,
key = AppSettings.KEY_TRACKER_ENABLED,
valueProducer = { isTrackerEnabled },
)
val counters = combine(
appUpdateRepository.observeAvailableUpdate(),
trackingRepository.observeUpdatedMangaCount(),

View File

@@ -1,16 +1,16 @@
package org.koitharu.kotatsu.main.ui.protect
import android.app.Activity
import android.app.Application
import android.content.Intent
import android.os.Bundle
import org.acra.dialog.CrashReportDialog
import org.koitharu.kotatsu.base.ui.DefaultActivityLifecycleCallbacks
import org.koitharu.kotatsu.core.prefs.AppSettings
import javax.inject.Inject
import javax.inject.Singleton
import org.acra.dialog.CrashReportDialog
import org.koitharu.kotatsu.core.prefs.AppSettings
@Singleton
class AppProtectHelper @Inject constructor(private val settings: AppSettings) : Application.ActivityLifecycleCallbacks {
class AppProtectHelper @Inject constructor(private val settings: AppSettings) : DefaultActivityLifecycleCallbacks {
private var isUnlocked = settings.appPassword.isNullOrEmpty()
@@ -27,16 +27,6 @@ class AppProtectHelper @Inject constructor(private val settings: AppSettings) :
}
}
override fun onActivityStarted(activity: Activity) = Unit
override fun onActivityResumed(activity: Activity) = Unit
override fun onActivityPaused(activity: Activity) = Unit
override fun onActivityStopped(activity: Activity) = Unit
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) {
if (activity !is ProtectActivity && activity.isFinishing && activity.isTaskRoot) {
restoreLock()

View File

@@ -7,14 +7,16 @@ import android.net.Uri
import androidx.collection.LongSparseArray
import androidx.collection.set
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import java.util.*
import java.util.concurrent.atomic.AtomicInteger
import java.util.zip.ZipFile
import javax.inject.Inject
import kotlinx.coroutines.*
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
@@ -30,7 +32,16 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import org.koitharu.kotatsu.utils.ext.withProgress
import org.koitharu.kotatsu.utils.progress.ProgressDeferred
import java.io.File
import java.util.LinkedList
import java.util.concurrent.atomic.AtomicInteger
import java.util.zip.ZipFile
import javax.inject.Inject
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
private const val PROGRESS_UNDEFINED = -1f
private const val PREFETCH_LIMIT_DEFAULT = 10
@@ -43,7 +54,7 @@ class PageLoader @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory,
) : Closeable {
val loaderScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val loaderScope = CoroutineScope(SupervisorJob() + InternalErrorHandler() + Dispatchers.Default)
private val connectivityManager = context.connectivityManager
private val tasks = LongSparseArray<ProgressDeferred<File, Float>>()
@@ -56,7 +67,9 @@ class PageLoader @Inject constructor(
override fun close() {
loaderScope.cancel()
tasks.clear()
synchronized(tasks) {
tasks.clear()
}
}
fun isPrefetchApplicable(): Boolean {
@@ -93,7 +106,9 @@ class PageLoader @Inject constructor(
return task
}
task = loadPageAsyncImpl(page)
tasks[page.id] = task
synchronized(tasks) {
tasks[page.id] = task
}
return task
}
@@ -125,7 +140,9 @@ class PageLoader @Inject constructor(
while (prefetchQueue.isNotEmpty()) {
val page = prefetchQueue.pollFirst() ?: return
if (cache[page.url] == null) {
tasks[page.id] = loadPageAsyncImpl(page)
synchronized(tasks) {
tasks[page.id] = loadPageAsyncImpl(page)
}
return
}
}
@@ -163,9 +180,12 @@ class PageLoader @Inject constructor(
val uri = Uri.parse(pageUrl)
return if (uri.scheme == "cbz") {
runInterruptible(Dispatchers.IO) {
val zip = ZipFile(uri.schemeSpecificPart)
val entry = zip.getEntry(uri.fragment)
zip.getInputStream(entry).use {
ZipFile(uri.schemeSpecificPart)
}.use { zip ->
runInterruptible(Dispatchers.IO) {
val entry = zip.getEntry(uri.fragment)
zip.getInputStream(entry)
}.use {
cache.put(pageUrl, it)
}
}
@@ -184,10 +204,8 @@ class PageLoader @Inject constructor(
val body = checkNotNull(response.body) {
"Null response"
}
runInterruptible(Dispatchers.IO) {
body.byteStream().use {
cache.put(pageUrl, it, body.contentLength(), progress)
}
body.withProgress(progress).byteStream().use {
cache.put(pageUrl, it)
}
}
}
@@ -197,4 +215,13 @@ class PageLoader @Inject constructor(
val deferred = CompletableDeferred(file)
return ProgressDeferred(deferred, emptyProgressFlow)
}
private class InternalErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler),
CoroutineExceptionHandler {
override fun handleException(context: CoroutineContext, exception: Throwable) {
exception.printStackTraceDebug()
}
}
}

View File

@@ -6,10 +6,6 @@ import android.webkit.MimeTypeMap
import androidx.activity.result.ActivityResultLauncher
import androidx.core.net.toUri
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import javax.inject.Inject
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -20,6 +16,11 @@ import org.koitharu.kotatsu.base.domain.MangaDataRepository
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.utils.ext.copyToSuspending
import java.io.File
import javax.inject.Inject
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
private const val MAX_FILENAME_LENGTH = 10
private const val EXTENSION_FALLBACK = "png"
@@ -48,12 +49,12 @@ class PageSaveHelper @Inject constructor(
}
}
runInterruptible(Dispatchers.IO) {
contentResolver.openOutputStream(destination)?.use { output ->
pageFile.inputStream().use { input ->
input.copyTo(output)
}
} ?: throw IOException("Output stream is null")
}
contentResolver.openOutputStream(destination)
}?.use { output ->
pageFile.inputStream().use { input ->
input.copyToSuspending(output)
}
} ?: throw IOException("Output stream is null")
return destination
}

View File

@@ -58,6 +58,7 @@ import org.koitharu.kotatsu.utils.ext.isReportable
import org.koitharu.kotatsu.utils.ext.observeWithPrevious
import org.koitharu.kotatsu.utils.ext.postDelayed
import org.koitharu.kotatsu.utils.ext.report
import org.koitharu.kotatsu.utils.ext.setValueRounded
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@@ -388,7 +389,7 @@ class ReaderActivity :
}
if (uiState.isSliderAvailable()) {
binding.slider.valueTo = uiState.totalPages.toFloat() - 1
binding.slider.value = uiState.currentPage.toFloat()
binding.slider.setValueRounded(uiState.currentPage.toFloat())
binding.slider.isVisible = true
} else {
binding.slider.isVisible = false

View File

@@ -106,6 +106,12 @@ class ReaderViewModel @AssistedInject constructor(
valueProducer = { isReaderBarEnabled },
)
val isWebtoonZoomEnabled = settings.observeAsLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default,
key = AppSettings.KEY_WEBTOON_ZOOM,
valueProducer = { isWebtoonZoomEnable },
)
val readerSettings = ReaderSettings(
parentScope = viewModelScope,
settings = settings,

View File

@@ -103,8 +103,8 @@ class ColorFilterConfigActivity :
}
private fun onColorFilterChanged(readerColorFilter: ReaderColorFilter?) {
binding.sliderBrightness.value = readerColorFilter?.brightness ?: 0f
binding.sliderContrast.value = readerColorFilter?.contrast ?: 0f
binding.sliderBrightness.setValueRounded(readerColorFilter?.brightness ?: 0f)
binding.sliderContrast.setValueRounded(readerColorFilter?.contrast ?: 0f)
binding.imageViewAfter.colorFilter = readerColorFilter?.toColorFilter()
}

View File

@@ -23,6 +23,7 @@ import org.koitharu.kotatsu.reader.ui.ReaderViewModel
import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.utils.ScreenOrientationHelper
import org.koitharu.kotatsu.utils.ext.setValueRounded
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import org.koitharu.kotatsu.utils.ext.withArgs
@@ -65,7 +66,7 @@ class ReaderConfigBottomSheet :
binding.sliderTimer.setLabelFormatter(PageSwitchTimer.DelayLabelFormatter(view.resources))
findCallback()?.run {
binding.sliderTimer.value = pageSwitchDelay
binding.sliderTimer.setValueRounded(pageSwitchDelay)
}
}
@@ -75,13 +76,16 @@ class ReaderConfigBottomSheet :
startActivity(SettingsActivity.newReaderSettingsIntent(v.context))
dismissAllowingStateLoss()
}
R.id.button_save_page -> {
val page = viewModel.getCurrentPage() ?: return
viewModel.saveCurrentPage(page, savePageRequest)
}
R.id.button_screen_rotate -> {
orientationHelper?.toggleOrientation()
}
R.id.button_color_filter -> {
val page = viewModel.getCurrentPage() ?: return
val manga = viewModel.manga ?: return

View File

@@ -2,9 +2,13 @@ package org.koitharu.kotatsu.reader.ui.config
import android.content.SharedPreferences
import androidx.lifecycle.MediatorLiveData
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
@@ -60,7 +64,7 @@ class ReaderSettings(
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
if (key == AppSettings.KEY_ZOOM_MODE || key == AppSettings.KEY_PAGES_NUMBERS) {
if (key == AppSettings.KEY_ZOOM_MODE || key == AppSettings.KEY_PAGES_NUMBERS || key == AppSettings.KEY_WEBTOON_ZOOM) {
notifyChanged()
}
}

View File

@@ -5,6 +5,7 @@ import androidx.annotation.CallSuper
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.LayoutPageInfoBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
@@ -13,11 +14,12 @@ abstract class BasePageHolder<B : ViewBinding>(
protected val binding: B,
loader: PageLoader,
settings: ReaderSettings,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback {
@Suppress("LeakingThis")
protected val delegate = PageHolderDelegate(loader, settings, this, exceptionResolver)
protected val delegate = PageHolderDelegate(loader, settings, this, networkState, exceptionResolver)
protected val bindingInfo = LayoutPageInfoBinding.bind(binding.root)
val context: Context

View File

@@ -4,17 +4,19 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.utils.ext.resetTransformations
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@Suppress("LeakingThis")
abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
private val loader: PageLoader,
private val readerSettings: ReaderSettings,
private val networkState: NetworkState,
private val exceptionResolver: ExceptionResolver,
) : RecyclerView.Adapter<H>() {
@@ -56,9 +58,9 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
final override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
): H = onCreateViewHolder(parent, loader, readerSettings, exceptionResolver)
): H = onCreateViewHolder(parent, loader, readerSettings, networkState, exceptionResolver)
suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine<Unit> { cont ->
suspend fun setItems(items: List<ReaderPage>) = suspendCoroutine { cont ->
differ.submitList(items) {
cont.resume(Unit)
}
@@ -68,6 +70,7 @@ abstract class BaseReaderAdapter<H : BasePageHolder<*>>(
parent: ViewGroup,
loader: PageLoader,
settings: ReaderSettings,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
): H

View File

@@ -9,6 +9,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
@@ -16,9 +17,11 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.utils.ext.printStackTraceDebug
import java.io.File
import java.io.IOException
@@ -26,6 +29,7 @@ class PageHolderDelegate(
private val loader: PageLoader,
private val readerSettings: ReaderSettings,
private val callback: Callback,
private val networkState: NetworkState,
private val exceptionResolver: ExceptionResolver,
) : DefaultOnImageEventListener, Observer<ReaderSettings> {
@@ -118,29 +122,36 @@ class PageHolderDelegate(
}
}
private suspend fun CoroutineScope.doLoad(data: MangaPage, force: Boolean) {
private suspend fun doLoad(data: MangaPage, force: Boolean) {
state = State.LOADING
error = null
callback.onLoadingStarted()
try {
val task = loader.loadPageAsync(data, force)
val progressObserver = observeProgress(this, task.progressAsFlow())
val file = task.await()
progressObserver.cancel()
this@PageHolderDelegate.file = file
file = coroutineScope {
val progressObserver = observeProgress(this, task.progressAsFlow())
val file = task.await()
progressObserver.cancel()
file
}
state = State.LOADED
callback.onImageReady(file.toUri())
callback.onImageReady(checkNotNull(file).toUri())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
} catch (e: Throwable) {
e.printStackTraceDebug()
state = State.ERROR
error = e
callback.onError(e)
if (e is IOException && !networkState.value) {
networkState.awaitForConnection()
retry(data)
}
}
}
private fun observeProgress(scope: CoroutineScope, progress: Flow<Float>) = progress
.debounce(500)
.debounce(250)
.onEach { callback.onProgressChanged((100 * it).toInt()) }
.launchIn(scope)

View File

@@ -6,6 +6,7 @@ import android.widget.FrameLayout
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
@@ -15,8 +16,9 @@ class ReversedPageHolder(
binding: ItemPageBinding,
loader: PageLoader,
settings: ReaderSettings,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : PageHolder(binding, loader, settings, exceptionResolver) {
) : PageHolder(binding, loader, settings, networkState, exceptionResolver) {
init {
(binding.textViewNumber.layoutParams as FrameLayout.LayoutParams)
@@ -35,6 +37,7 @@ class ReversedPageHolder(
minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE
resetScaleAndCenter()
}
ZoomMode.FIT_HEIGHT -> {
minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM
minScale = height / sHeight.toFloat()
@@ -43,6 +46,7 @@ class ReversedPageHolder(
PointF(sWidth.toFloat(), sHeight / 2f),
)
}
ZoomMode.FIT_WIDTH -> {
minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM
minScale = width / sWidth.toFloat()
@@ -51,6 +55,7 @@ class ReversedPageHolder(
PointF(sWidth / 2f, 0f),
)
}
ZoomMode.KEEP_START -> {
minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE
setScaleAndCenter(

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui.pager.reversed
import android.view.LayoutInflater
import android.view.ViewGroup
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
@@ -11,18 +12,21 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class ReversedPagesAdapter(
loader: PageLoader,
settings: ReaderSettings,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<ReversedPageHolder>(loader, settings, exceptionResolver) {
) : BaseReaderAdapter<ReversedPageHolder>(loader, settings, networkState, exceptionResolver) {
override fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: ReaderSettings,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) = ReversedPageHolder(
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader,
settings = settings,
networkState = networkState,
exceptionResolver = exceptionResolver,
)
}

View File

@@ -7,8 +7,8 @@ import android.view.View
import android.view.ViewGroup
import androidx.core.view.children
import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.absoluteValue
import kotlinx.coroutines.async
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader
@@ -19,10 +19,15 @@ import org.koitharu.kotatsu.utils.ext.doOnPageChanged
import org.koitharu.kotatsu.utils.ext.recyclerView
import org.koitharu.kotatsu.utils.ext.resetTransformations
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import javax.inject.Inject
import kotlin.math.absoluteValue
@AndroidEntryPoint
class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@Inject
lateinit var networkState: NetworkState
private var pagerAdapter: ReversedPagesAdapter? = null
override fun onInflateView(
@@ -33,7 +38,12 @@ class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@SuppressLint("NotifyDataSetChanged")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
pagerAdapter = ReversedPagesAdapter(viewModel.pageLoader, viewModel.readerSettings, exceptionResolver)
pagerAdapter = ReversedPagesAdapter(
viewModel.pageLoader,
viewModel.readerSettings,
networkState,
exceptionResolver,
)
with(binding.pager) {
adapter = pagerAdapter
offscreenPageLimit = 2
@@ -44,8 +54,8 @@ class ReversedReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
val transformer = if (it) ReversedPageAnimTransformer() else null
binding.pager.setPageTransformer(transformer)
if (transformer == null) {
binding.pager.recyclerView?.children?.forEach {
it.resetTransformations()
binding.pager.recyclerView?.children?.forEach { v ->
v.resetTransformations()
}
}
}

View File

@@ -10,6 +10,7 @@ import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.model.ZoomMode
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
@@ -21,8 +22,9 @@ open class PageHolder(
binding: ItemPageBinding,
loader: PageLoader,
settings: ReaderSettings,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageBinding>(binding, loader, settings, exceptionResolver),
) : BasePageHolder<ItemPageBinding>(binding, loader, settings, networkState, exceptionResolver),
View.OnClickListener {
init {
@@ -74,6 +76,7 @@ open class PageHolder(
binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE
binding.ssiv.resetScaleAndCenter()
}
ZoomMode.FIT_HEIGHT -> {
binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM
binding.ssiv.minScale = binding.ssiv.height / binding.ssiv.sHeight.toFloat()
@@ -82,6 +85,7 @@ open class PageHolder(
PointF(0f, binding.ssiv.sHeight / 2f),
)
}
ZoomMode.FIT_WIDTH -> {
binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM
binding.ssiv.minScale = binding.ssiv.width / binding.ssiv.sWidth.toFloat()
@@ -90,6 +94,7 @@ open class PageHolder(
PointF(binding.ssiv.sWidth / 2f, 0f),
)
}
ZoomMode.KEEP_START -> {
binding.ssiv.minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE
binding.ssiv.setScaleAndCenter(

View File

@@ -7,8 +7,8 @@ import android.view.View
import android.view.ViewGroup
import androidx.core.view.children
import dagger.hilt.android.AndroidEntryPoint
import kotlin.math.absoluteValue
import kotlinx.coroutines.async
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader
@@ -18,10 +18,15 @@ import org.koitharu.kotatsu.utils.ext.doOnPageChanged
import org.koitharu.kotatsu.utils.ext.recyclerView
import org.koitharu.kotatsu.utils.ext.resetTransformations
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import javax.inject.Inject
import kotlin.math.absoluteValue
@AndroidEntryPoint
class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@Inject
lateinit var networkState: NetworkState
private var pagesAdapter: PagesAdapter? = null
override fun onInflateView(
@@ -32,7 +37,12 @@ class PagerReaderFragment : BaseReader<FragmentReaderStandardBinding>() {
@SuppressLint("NotifyDataSetChanged")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
pagesAdapter = PagesAdapter(viewModel.pageLoader, viewModel.readerSettings, exceptionResolver)
pagesAdapter = PagesAdapter(
viewModel.pageLoader,
viewModel.readerSettings,
networkState,
exceptionResolver,
)
with(binding.pager) {
adapter = pagesAdapter
offscreenPageLimit = 2

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.reader.ui.pager.standard
import android.view.LayoutInflater
import android.view.ViewGroup
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
@@ -11,18 +12,21 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class PagesAdapter(
loader: PageLoader,
settings: ReaderSettings,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<PageHolder>(loader, settings, exceptionResolver) {
) : BaseReaderAdapter<PageHolder>(loader, settings, networkState, exceptionResolver) {
override fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: ReaderSettings,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) = PageHolder(
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader,
settings = settings,
networkState = networkState,
exceptionResolver = exceptionResolver,
)
}

View File

@@ -3,7 +3,7 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.view.LayoutInflater
import android.view.ViewGroup
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
@@ -12,13 +12,15 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class WebtoonAdapter(
loader: PageLoader,
settings: ReaderSettings,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<WebtoonHolder>(loader, settings, exceptionResolver) {
) : BaseReaderAdapter<WebtoonHolder>(loader, settings, networkState, exceptionResolver) {
override fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: ReaderSettings,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) = WebtoonHolder(
binding = ItemPageWebtoonBinding.inflate(
@@ -28,6 +30,7 @@ class WebtoonAdapter(
),
loader = loader,
settings = settings,
networkState = networkState,
exceptionResolver = exceptionResolver,
)
}

View File

@@ -8,20 +8,26 @@ import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import com.davemorrissey.labs.subscaleview.decoder.SkiaPooledImageRegionDecoder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.ItemPageWebtoonBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BasePageHolder
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.GoneOnInvisibleListener
import org.koitharu.kotatsu.utils.ext.*
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.hideCompat
import org.koitharu.kotatsu.utils.ext.ifZero
import org.koitharu.kotatsu.utils.ext.setProgressCompat
import org.koitharu.kotatsu.utils.ext.showCompat
class WebtoonHolder(
binding: ItemPageWebtoonBinding,
loader: PageLoader,
settings: ReaderSettings,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, exceptionResolver),
) : BasePageHolder<ItemPageWebtoonBinding>(binding, loader, settings, networkState, exceptionResolver),
View.OnClickListener {
private var scrollToRestore = 0

View File

@@ -7,6 +7,7 @@ import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.FragmentReaderWebtoonBinding
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReader
@@ -15,10 +16,14 @@ import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.utils.ext.findCenterViewPosition
import org.koitharu.kotatsu.utils.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
import javax.inject.Inject
@AndroidEntryPoint
class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
@Inject
lateinit var networkState: NetworkState
private val scrollInterpolator = AccelerateDecelerateInterpolator()
private var webtoonAdapter: WebtoonAdapter? = null
@@ -29,12 +34,21 @@ class WebtoonReaderFragment : BaseReader<FragmentReaderWebtoonBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
webtoonAdapter = WebtoonAdapter(viewModel.pageLoader, viewModel.readerSettings, exceptionResolver)
webtoonAdapter = WebtoonAdapter(
viewModel.pageLoader,
viewModel.readerSettings,
networkState,
exceptionResolver,
)
with(binding.recyclerView) {
setHasFixedSize(true)
adapter = webtoonAdapter
addOnPageScrollListener(PageScrollListener())
}
viewModel.isWebtoonZoomEnabled.observe(viewLifecycleOwner) {
binding.frame.isZoomEnable = it
}
}
override fun onDestroyView() {

View File

@@ -0,0 +1,213 @@
package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.Matrix
import android.graphics.Rect
import android.graphics.RectF
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.FrameLayout
import android.widget.OverScroller
import androidx.core.view.GestureDetectorCompat
private const val MAX_SCALE = 2.5f
private const val MIN_SCALE = 1f // under-scaling disabled due to buggy nested scroll
class WebtoonScalingFrame @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyles: Int = 0,
) : FrameLayout(context, attrs, defStyles), ScaleGestureDetector.OnScaleGestureListener {
private val targetChild by lazy(LazyThreadSafetyMode.NONE) { getChildAt(0) }
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
get() = matrixValues[Matrix.MSCALE_X]
private val transX
get() = halfWidth * (scale - 1f) + matrixValues[Matrix.MTRANS_X]
private val transY
get() = halfHeight * (scale - 1f) + matrixValues[Matrix.MTRANS_Y]
private var halfWidth = 0f
private var halfHeight = 0f
private val translateBounds = RectF()
private val targetHitRect = Rect()
private var pendingScroll = 0
var isZoomEnable = true
set(value) {
field = value
if (scale != 1f) {
scaleChild(1f, halfWidth, halfHeight)
}
}
init {
syncMatrixValues()
}
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
if (!isZoomEnable || ev == null) {
return super.dispatchTouchEvent(ev)
}
if (ev.action == MotionEvent.ACTION_DOWN && overScroller.computeScrollOffset()) {
overScroller.forceFinished(true)
}
gestureDetector.onTouchEvent(ev)
scaleDetector.onTouchEvent(ev)
// Offset event to inside the child view
if (scale < 1 && !targetHitRect.contains(ev.x.toInt(), ev.y.toInt())) {
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)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
halfWidth = measuredWidth / 2f
halfHeight = measuredHeight / 2f
}
private fun invalidateTarget() {
adjustBounds()
targetChild.run {
scaleX = scale
scaleY = scale
translationX = transX
translationY = transY
}
val newHeight = if (scale < 1f) (height / scale).toInt() else height
if (newHeight != targetChild.height) {
targetChild.layoutParams.height = newHeight
targetChild.requestLayout()
}
if (scale < 1) {
targetChild.getHitRect(targetHitRect)
targetChild.scrollBy(0, pendingScroll)
pendingScroll = 0
}
}
private fun syncMatrixValues() {
transformMatrix.getValues(matrixValues)
}
private fun adjustBounds() {
syncMatrixValues()
val dx = when {
transX < translateBounds.left -> translateBounds.left - transX
transX > translateBounds.right -> translateBounds.right - transX
else -> 0f
}
val dy = when {
transY < translateBounds.top -> translateBounds.top - transY
transY > translateBounds.bottom -> translateBounds.bottom - transY
else -> 0f
}
pendingScroll = dy.toInt()
transformMatrix.postTranslate(dx, dy)
syncMatrixValues()
}
private fun scaleChild(newScale: Float, focusX: Float, focusY: Float) {
val factor = newScale / scale
if (newScale > 1) {
translateBounds.set(
halfWidth * (1 - newScale),
halfHeight * (1 - newScale),
halfWidth * (newScale - 1),
halfHeight * (newScale - 1),
)
} else {
translateBounds.set(
0f,
halfHeight - halfHeight / newScale,
0f,
halfHeight - halfHeight / newScale,
)
}
transformMatrix.postScale(factor, factor, focusX, focusY)
invalidateTarget()
}
override fun onScale(detector: ScaleGestureDetector): Boolean {
val newScale = (scale * detector.scaleFactor).coerceIn(MIN_SCALE, MAX_SCALE)
scaleChild(newScale, detector.focusX, detector.focusY)
return true
}
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = true
override fun onScaleEnd(p0: ScaleGestureDetector) {
pendingScroll = 0
}
private inner class GestureListener : GestureDetector.SimpleOnGestureListener(), Runnable {
override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
if (scale <= 1f) return false
transformMatrix.postTranslate(-distanceX, -distanceY)
invalidateTarget()
return true
}
override fun onDoubleTap(e: MotionEvent): Boolean {
val newScale = if (scale != 1f) 1f else MAX_SCALE * 0.8f
ObjectAnimator.ofFloat(scale, newScale).run {
interpolator = AccelerateDecelerateInterpolator()
duration = 300
addUpdateListener {
scaleChild(it.animatedValue as Float, e.x, e.y)
}
start()
}
return true
}
override fun onFling(e1: MotionEvent, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean {
if (scale <= 1) return false
overScroller.fling(
transX.toInt(),
transY.toInt(),
velocityX.toInt(),
velocityY.toInt(),
translateBounds.left.toInt(),
translateBounds.right.toInt(),
translateBounds.top.toInt(),
translateBounds.bottom.toInt(),
)
postOnAnimation(this)
return true
}
override fun run() {
if (overScroller.computeScrollOffset()) {
transformMatrix.postTranslate(overScroller.currX - transX, overScroller.currY - transY)
invalidateTarget()
postOnAnimation(this)
}
}
}
}

View File

@@ -163,7 +163,7 @@ class RemoteListViewModel @AssistedInject constructor(
}
private fun createEmptyState(canResetFilter: Boolean) = EmptyState(
icon = R.drawable.ic_empty_search,
icon = R.drawable.ic_empty_common,
textPrimary = R.string.nothing_found,
textSecondary = 0,
actionStringRes = if (canResetFilter) R.string.reset_filter else 0,

View File

@@ -6,6 +6,7 @@ import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.widget.SearchView
import androidx.core.graphics.Insets
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.commit
import dagger.hilt.android.AndroidEntryPoint
@@ -15,7 +16,6 @@ import org.koitharu.kotatsu.databinding.ActivitySearchBinding
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.utils.ext.showKeyboard
import kotlin.text.Typography.dagger
@AndroidEntryPoint
class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQueryTextListener {
@@ -32,6 +32,7 @@ class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQuery
}
val query = intent.getStringExtra(EXTRA_QUERY)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged)
with(binding.searchView) {
queryHint = getString(R.string.search_on_s, source.title)
setOnQueryTextListener(this@SearchActivity)
@@ -72,6 +73,16 @@ class SearchActivity : BaseActivity<ActivitySearchBinding>(), SearchView.OnQuery
override fun onQueryTextChange(newText: String?): Boolean = false
private fun onIncognitoModeChanged(isIncognito: Boolean) {
var options = binding.searchView.imeOptions
options = if (isIncognito) {
options or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING
} else {
options and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv()
}
binding.searchView.imeOptions = options
}
companion object {
private const val EXTRA_SOURCE = "source"

View File

@@ -48,7 +48,7 @@ class SearchViewModel @AssistedInject constructor(
list == null -> listOf(LoadingState)
list.isEmpty() -> listOf(
EmptyState(
icon = R.drawable.ic_empty_search,
icon = R.drawable.ic_empty_common,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0,

View File

@@ -48,7 +48,7 @@ class MultiSearchViewModel @AssistedInject constructor(
loading -> LoadingState
error != null -> error.toErrorState(canRetry = true)
else -> EmptyState(
icon = R.drawable.ic_empty_search,
icon = R.drawable.ic_empty_common,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_search_holder_secondary,
actionStringRes = 0,

View File

@@ -3,17 +3,28 @@ package org.koitharu.kotatsu.search.ui.suggestion
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsLiveData
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
import javax.inject.Inject
private const val DEBOUNCE_TIMEOUT = 500L
private const val MAX_MANGA_ITEMS = 6
@@ -30,6 +41,12 @@ class SearchSuggestionViewModel @Inject constructor(
private val query = MutableStateFlow("")
private var suggestionJob: Job? = null
val isIncognitoModeEnabled = settings.observeAsLiveData(
context = viewModelScope.coroutineContext + Dispatchers.Default,
key = AppSettings.KEY_INCOGNITO_MODE,
valueProducer = { isIncognitoModeEnabled },
)
val suggestion = MutableLiveData<List<SearchSuggestionItem>>()
init {
@@ -41,7 +58,11 @@ class SearchSuggestionViewModel @Inject constructor(
}
fun saveQuery(query: String) {
repository.saveSearchQuery(query)
launchJob(Dispatchers.Default) {
if (!settings.isIncognitoModeEnabled) {
repository.saveSearchQuery(query)
}
}
}
fun clearSearchHistory() {

View File

@@ -86,7 +86,6 @@ class SourceSettingsFragment : BasePreferenceFragment(0) {
}.onSuccess { username ->
preference.title = getString(R.string.logged_in_as, username)
}.onFailure { error ->
preference.isEnabled = error is AuthRequiredException
when {
error is AuthRequiredException -> Unit
ExceptionResolver.canResolve(error) -> {

View File

@@ -11,8 +11,8 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.AlertDialogFragment
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.DialogOnboardBinding
import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocaleListener
import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocalesAdapter
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
import org.koitharu.kotatsu.utils.ext.showAllowStateLoss
@@ -21,8 +21,7 @@ import org.koitharu.kotatsu.utils.ext.withArgs
@AndroidEntryPoint
class OnboardDialogFragment :
AlertDialogFragment<DialogOnboardBinding>(),
OnListItemClickListener<SourceLocale>,
DialogInterface.OnClickListener {
DialogInterface.OnClickListener, SourceLocaleListener {
private val viewModel by viewModels<OnboardViewModel>()
private var isWelcome: Boolean = false
@@ -63,8 +62,8 @@ class OnboardDialogFragment :
}
}
override fun onItemClick(item: SourceLocale, view: View) {
viewModel.setItemChecked(item.key, !item.isChecked)
override fun onItemCheckedChanged(item: SourceLocale, isChecked: Boolean) {
viewModel.setItemChecked(item.key, isChecked)
}
override fun onClick(dialog: DialogInterface?, which: Int) {

View File

@@ -1,11 +1,8 @@
package org.koitharu.kotatsu.settings.onboard
import androidx.collection.ArraySet
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.MutableLiveData
import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.*
import javax.inject.Inject
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -15,6 +12,8 @@ import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
import org.koitharu.kotatsu.utils.ext.map
import org.koitharu.kotatsu.utils.ext.mapToSet
import java.util.Locale
import javax.inject.Inject
@HiltViewModel
class OnboardViewModel @Inject constructor(
@@ -23,15 +22,15 @@ class OnboardViewModel @Inject constructor(
private val allSources = settings.remoteMangaSources
private val locales = allSources.mapTo(ArraySet()) { it.locale }
private val locales = allSources.groupBy { it.locale }
private val selectedLocales = locales.toMutableSet()
private val selectedLocales = locales.keys.toMutableSet()
val list = MutableLiveData<List<SourceLocale>?>()
init {
if (settings.isSourcesSelected) {
selectedLocales.removeAll(settings.hiddenSources.mapNotNullToSet { x -> MangaSource(x)?.locale })
selectedLocales.removeAll(settings.hiddenSources.mapNotNullToSet { x -> MangaSource(x).locale })
} else {
val deviceLocales = LocaleListCompat.getDefault().mapToSet { x ->
x.language
@@ -64,13 +63,14 @@ class OnboardViewModel @Inject constructor(
}
private fun rebuildList() {
list.value = locales.map { key ->
list.value = locales.map { (key, srcs) ->
val locale = if (key != null) {
Locale(key)
} else null
SourceLocale(
key = key,
title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale),
summary = srcs.joinToString { it.title },
isChecked = key in selectedLocales,
)
}.sortedWith(SourceLocaleComparator())
@@ -87,11 +87,12 @@ class OnboardViewModel @Inject constructor(
a?.key == null -> 1
b?.key == null -> -1
else -> {
val index = deviceLocales.indexOf(a.key)
if (index == -1) {
val indexA = deviceLocales.indexOf(a.key)
val indexB = deviceLocales.indexOf(b.key)
if (indexA == -1 && indexB == -1) {
compareValues(a.title, b.title)
} else {
-2 - index
-2 - (indexA - indexB)
}
}
}

View File

@@ -2,22 +2,23 @@ package org.koitharu.kotatsu.settings.onboard.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.databinding.ItemSourceLocaleBinding
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
import org.koitharu.kotatsu.utils.ext.textAndVisible
fun sourceLocaleAD(
clickListener: OnListItemClickListener<SourceLocale>
listener: SourceLocaleListener,
) = adapterDelegateViewBinding<SourceLocale, SourceLocale, ItemSourceLocaleBinding>(
{ inflater, parent -> ItemSourceLocaleBinding.inflate(inflater, parent, false) }
{ inflater, parent -> ItemSourceLocaleBinding.inflate(inflater, parent, false) },
) {
binding.root.setOnClickListener {
clickListener.onItemClick(item, it)
binding.switchToggle.setOnCheckedChangeListener { _, isChecked ->
listener.onItemCheckedChanged(item, isChecked)
}
bind {
binding.root.text = item.title ?: getString(R.string.other)
binding.root.isChecked = item.isChecked
binding.textViewTitle.text = item.title ?: getString(R.string.different_languages)
binding.textViewDescription.textAndVisible = item.summary
binding.switchToggle.isChecked = item.isChecked
}
}
}

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.settings.onboard.adapter
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
interface SourceLocaleListener {
fun onItemCheckedChanged(item: SourceLocale, isChecked: Boolean)
}

View File

@@ -2,15 +2,14 @@ package org.koitharu.kotatsu.settings.onboard.adapter
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.settings.onboard.model.SourceLocale
class SourceLocalesAdapter(
clickListener: OnListItemClickListener<SourceLocale>,
listener: SourceLocaleListener,
) : AsyncListDifferDelegationAdapter<SourceLocale>(DiffCallback()) {
init {
delegatesManager.addDelegate(sourceLocaleAD(clickListener))
delegatesManager.addDelegate(sourceLocaleAD(listener))
}
private class DiffCallback : DiffUtil.ItemCallback<SourceLocale>() {
@@ -25,4 +24,4 @@ class SourceLocalesAdapter(
newItem: SourceLocale,
): Boolean = oldItem == newItem
}
}
}

View File

@@ -1,10 +1,11 @@
package org.koitharu.kotatsu.settings.onboard.model
import java.util.*
import java.util.Locale
data class SourceLocale(
val key: String?,
val title: String?,
val summary: String?,
val isChecked: Boolean,
) : Comparable<SourceLocale> {

View File

@@ -0,0 +1,35 @@
package org.koitharu.kotatsu.shelf.domain
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.history.domain.MangaWithHistory
import org.koitharu.kotatsu.parsers.model.Manga
class ShelfContent(
val history: List<MangaWithHistory>,
val favourites: Map<FavouriteCategory, List<Manga>>,
val updated: Map<Manga, Int>,
val local: List<Manga>,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ShelfContent
if (history != other.history) return false
if (favourites != other.favourites) return false
if (updated != other.updated) return false
if (local != other.local) return false
return true
}
override fun hashCode(): Int {
var result = history.hashCode()
result = 31 * result + favourites.hashCode()
result = 31 * result + updated.hashCode()
result = 31 * result + local.hashCode()
return result
}
}

View File

@@ -1,19 +1,55 @@
package org.koitharu.kotatsu.shelf.domain
import javax.inject.Inject
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.ext.runCatchingCancellable
import javax.inject.Inject
class ShelfRepository @Inject constructor(
private val localMangaRepository: LocalMangaRepository,
private val historyRepository: HistoryRepository,
private val trackingRepository: TrackingRepository,
private val db: MangaDatabase,
) {
fun observeShelfContent(): Flow<ShelfContent> = combine(
historyRepository.observeAllWithHistory(),
observeLocalManga(SortOrder.UPDATED),
observeFavourites(),
trackingRepository.observeUpdatedManga(),
) { history, local, favorites, updated ->
ShelfContent(history, favorites, updated, local)
}
fun observeLocalManga(sortOrder: SortOrder): Flow<List<Manga>> {
return flow {
emit(null)
emitAll(localMangaRepository.watchReadableDirs())
}.mapLatest {
localMangaRepository.getList(0, null, sortOrder)
}
}
fun observeFavourites(): Flow<Map<FavouriteCategory, List<Manga>>> {
return db.favouriteCategoriesDao.observeAll()
.flatMapLatest { categories ->
@@ -26,6 +62,23 @@ class ShelfRepository @Inject constructor(
}
}
suspend fun deleteLocalManga(ids: Set<Long>) {
val list = localMangaRepository.getList(0, null, null)
.filter { x -> x.id in ids }
coroutineScope {
list.map { manga ->
async {
val original = localMangaRepository.getRemoteManga(manga)
if (localMangaRepository.delete(manga)) {
runCatchingCancellable {
historyRepository.deleteOrSwap(manga, original)
}
}
}
}.awaitAll()
}
}
private fun observeCategoriesContent(
categories: List<FavouriteCategoryEntity>,
) = combine<Pair<FavouriteCategory, List<Manga>>, Map<FavouriteCategory, List<Manga>>>(

View File

@@ -0,0 +1,6 @@
package org.koitharu.kotatsu.shelf.domain
enum class ShelfSection {
HISTORY, LOCAL, UPDATED, FAVORITES;
}

View File

@@ -1,6 +1,9 @@
package org.koitharu.kotatsu.shelf.ui
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -11,7 +14,6 @@ import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.domain.reverseAsync
import org.koitharu.kotatsu.base.ui.BaseFragment
@@ -23,15 +25,19 @@ import org.koitharu.kotatsu.databinding.FragmentShelfBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.favourites.ui.FavouritesActivity
import org.koitharu.kotatsu.history.ui.HistoryActivity
import org.koitharu.kotatsu.shelf.ui.adapter.ShelfAdapter
import org.koitharu.kotatsu.shelf.ui.adapter.ShelfListEventListener
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.list.ui.ItemSizeResolver
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.shelf.ui.adapter.ShelfAdapter
import org.koitharu.kotatsu.shelf.ui.adapter.ShelfListEventListener
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity
import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import javax.inject.Inject
@AndroidEntryPoint
class ShelfFragment :
@@ -102,13 +108,22 @@ class ShelfFragment :
val intent = when (section) {
is ShelfSectionModel.History -> HistoryActivity.newIntent(view.context)
is ShelfSectionModel.Favourites -> FavouritesActivity.newIntent(view.context, section.category)
is ShelfSectionModel.Updated -> UpdatesActivity.newIntent(view.context)
is ShelfSectionModel.Local -> MangaListActivity.newIntent(view.context, MangaSource.LOCAL)
}
startActivity(intent)
}
override fun onRetryClick(error: Throwable) = Unit
override fun onEmptyActionClick() = Unit
override fun onEmptyActionClick() {
val action = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
Settings.Panel.ACTION_INTERNET_CONNECTIVITY
} else {
Settings.ACTION_WIRELESS_SETTINGS
}
startActivity(Intent(action))
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.recyclerView.updatePadding(

View File

@@ -6,16 +6,16 @@ import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import androidx.fragment.app.FragmentManager
import com.google.android.material.R as materialR
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import java.util.*
import java.util.concurrent.TimeUnit
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.RememberSelectionDialogListener
import org.koitharu.kotatsu.shelf.ui.config.categories.ShelfCategoriesConfigSheet
import org.koitharu.kotatsu.shelf.ui.config.size.ShelfSizeBottomSheet
import org.koitharu.kotatsu.local.ui.ImportDialogFragment
import org.koitharu.kotatsu.shelf.ui.config.ShelfSettingsActivity
import org.koitharu.kotatsu.shelf.ui.config.size.ShelfSizeBottomSheet
import org.koitharu.kotatsu.utils.ext.startOfDay
import java.util.Date
import java.util.concurrent.TimeUnit
import com.google.android.material.R as materialR
class ShelfMenuProvider(
private val context: Context,
@@ -33,18 +33,22 @@ class ShelfMenuProvider(
showClearHistoryDialog()
true
}
R.id.action_grid_size -> {
ShelfSizeBottomSheet.show(fragmentManager)
true
}
R.id.action_import -> {
ImportDialogFragment.show(fragmentManager)
true
}
R.id.action_categories -> {
ShelfCategoriesConfigSheet.show(fragmentManager)
context.startActivity(ShelfSettingsActivity.newIntent(context))
true
}
else -> false
}
}

View File

@@ -6,15 +6,16 @@ import android.view.MenuItem
import androidx.appcompat.view.ActionMode
import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.base.ui.list.decor.AbstractSelectionItemDecoration
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.favourites.ui.categories.select.FavouriteCategoriesBottomSheet
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.flattenTo
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.invalidateNestedItemDecorations
@@ -41,8 +42,10 @@ class ShelfSelectionCallback(
mode: ActionMode,
menu: Menu,
): Boolean {
menu.findItem(R.id.action_remove).isVisible =
controller.peekCheckedIds().count { (_, v) -> v.isNotEmpty() } == 1
val checkedIds = controller.peekCheckedIds().entries
val singleKey = checkedIds.singleOrNull { (_, ids) -> ids.isNotEmpty() }?.key
menu.findItem(R.id.action_remove)?.isVisible = singleKey != null && singleKey !is ShelfSectionModel.Updated
menu.findItem(R.id.action_save)?.isVisible = singleKey !is ShelfSectionModel.Local
return super.onPrepareActionMode(controller, mode, menu)
}
@@ -57,25 +60,34 @@ class ShelfSelectionCallback(
mode.finish()
true
}
R.id.action_favourite -> {
FavouriteCategoriesBottomSheet.show(fragmentManager, collectSelectedItems(controller))
mode.finish()
true
}
R.id.action_save -> {
DownloadService.confirmAndStart(context, collectSelectedItems(controller))
mode.finish()
true
}
R.id.action_remove -> {
val (group, ids) = controller.snapshot().entries.singleOrNull { it.value.isNotEmpty() } ?: return false
when (group) {
is ShelfSectionModel.Favourites -> viewModel.removeFromFavourites(group.category, ids)
is ShelfSectionModel.History -> viewModel.removeFromHistory(ids)
is ShelfSectionModel.Updated -> return false
is ShelfSectionModel.Local -> {
showDeletionConfirm(ids, mode)
return true
}
}
mode.finish()
true
}
else -> false
}
}
@@ -108,4 +120,19 @@ class ShelfSelectionCallback(
}
return viewModel.getManga(snapshot.values.flattenTo(HashSet()))
}
private fun showDeletionConfirm(ids: Set<Long>, mode: ActionMode) {
if (ids.isEmpty()) {
return
}
MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.delete_manga)
.setMessage(context.getString(R.string.text_delete_local_manga_batch))
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.deleteLocal(ids)
mode.finish()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}

View File

@@ -4,53 +4,63 @@ import androidx.collection.ArraySet
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.*
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.base.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.history.domain.MangaWithHistory
import org.koitharu.kotatsu.history.domain.PROGRESS_NONE
import org.koitharu.kotatsu.shelf.domain.ShelfRepository
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.ui.model.*
import org.koitharu.kotatsu.list.ui.model.EmptyHint
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toGridModel
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.shelf.domain.ShelfContent
import org.koitharu.kotatsu.shelf.domain.ShelfRepository
import org.koitharu.kotatsu.shelf.domain.ShelfSection
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.daysDiff
private const val HISTORY_MAX_SEGMENTS = 2
import javax.inject.Inject
@HiltViewModel
class ShelfViewModel @Inject constructor(
repository: ShelfRepository,
private val repository: ShelfRepository,
private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository,
private val trackingRepository: TrackingRepository,
private val settings: AppSettings,
networkState: NetworkState,
) : BaseViewModel(), ListExtraProvider {
val onActionDone = SingleLiveEvent<ReversibleAction>()
val content: LiveData<List<ListModel>> = combine(
historyRepository.observeAllWithHistory(),
repository.observeFavourites(),
) { history, favourites ->
mapList(history, favourites)
}.catch { e ->
emit(listOf(e.toErrorState(canRetry = false)))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
settings.observeAsFlow(AppSettings.KEY_SHELF_SECTIONS) { shelfSections },
networkState,
repository.observeShelfContent(),
) { sections, isConnected, content ->
mapList(content, sections, isConnected)
}.debounce(500)
.catch { e ->
emit(listOf(e.toErrorState(canRetry = false)))
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
override suspend fun getCounter(mangaId: Long): Int {
return trackingRepository.getNewChaptersCount(mangaId)
@@ -84,6 +94,13 @@ class ShelfViewModel @Inject constructor(
}
}
fun deleteLocal(ids: Set<Long>) {
launchLoadingJob(Dispatchers.Default) {
repository.deleteLocalManga(ids)
onActionDone.postCall(ReversibleAction(R.string.removal_completed, null))
}
}
fun clearHistory(minDate: Long) {
launchJob(Dispatchers.Default) {
val stringRes = if (minDate <= 0) {
@@ -117,15 +134,39 @@ class ShelfViewModel @Inject constructor(
}
private suspend fun mapList(
history: List<MangaWithHistory>,
favourites: Map<FavouriteCategory, List<Manga>>,
content: ShelfContent,
sections: List<ShelfSection>,
isNetworkAvailable: Boolean,
): List<ListModel> {
val result = ArrayList<ListModel>(favourites.keys.size + 1)
if (history.isNotEmpty()) {
mapHistory(result, history)
}
if (favourites.isNotEmpty()) {
mapFavourites(result, favourites)
val result = ArrayList<ListModel>(content.favourites.keys.size + 3)
if (isNetworkAvailable) {
for (section in sections) {
when (section) {
ShelfSection.HISTORY -> mapHistory(result, content.history)
ShelfSection.LOCAL -> mapLocal(result, content.local)
ShelfSection.UPDATED -> mapUpdated(result, content.updated)
ShelfSection.FAVORITES -> mapFavourites(result, content.favourites)
}
}
} else {
result += EmptyHint(
icon = R.drawable.ic_empty_common,
textPrimary = R.string.network_unavailable,
textSecondary = R.string.network_unavailable_hint,
actionStringRes = R.string.manage,
)
for (section in sections) {
when (section) {
ShelfSection.HISTORY -> mapHistory(
result,
content.history.filter { it.manga.source == MangaSource.LOCAL },
)
ShelfSection.LOCAL -> mapLocal(result, content.local)
ShelfSection.UPDATED -> Unit
ShelfSection.FAVORITES -> Unit
}
}
}
if (result.isEmpty()) {
result += EmptyState(
@@ -134,8 +175,12 @@ class ShelfViewModel @Inject constructor(
textSecondary = R.string.text_shelf_holder_secondary,
actionStringRes = 0,
)
} else {
val one = result.singleOrNull()
if (one is EmptyHint) {
result[0] = one.toState()
}
}
result.trimToSize()
return result
}
@@ -143,30 +188,57 @@ class ShelfViewModel @Inject constructor(
destination: MutableList<in ShelfSectionModel.History>,
list: List<MangaWithHistory>,
) {
if (list.isEmpty()) {
return
}
val showPercent = settings.isReadingIndicatorsEnabled
val groups = list.groupByTo(LinkedHashMap()) { timeAgo(it.history.updatedAt) }
while (groups.size > HISTORY_MAX_SEGMENTS) {
val lastKey = groups.keys.last()
val subList = groups.remove(lastKey) ?: continue
groups[groups.keys.last()]?.addAll(subList)
destination += ShelfSectionModel.History(
items = list.map { (manga, history) ->
val counter = trackingRepository.getNewChaptersCount(manga.id)
val percent = if (showPercent) history.percent else PROGRESS_NONE
manga.toGridModel(counter, percent)
},
showAllButtonText = R.string.show_all,
)
}
private suspend fun mapUpdated(
destination: MutableList<in ShelfSectionModel.Updated>,
updated: Map<Manga, Int>,
) {
if (updated.isEmpty()) {
return
}
for ((timeAgo, subList) in groups) {
destination += ShelfSectionModel.History(
items = subList.map { (manga, history) ->
val counter = trackingRepository.getNewChaptersCount(manga.id)
val percent = if (showPercent) history.percent else PROGRESS_NONE
manga.toGridModel(counter, percent)
},
timeAgo = timeAgo,
showAllButtonText = R.string.show_all,
)
val showPercent = settings.isReadingIndicatorsEnabled
destination += ShelfSectionModel.Updated(
items = updated.map { (manga, counter) ->
val percent = if (showPercent) getProgress(manga.id) else PROGRESS_NONE
manga.toGridModel(counter, percent)
},
showAllButtonText = R.string.show_all,
)
}
private suspend fun mapLocal(
destination: MutableList<in ShelfSectionModel.Local>,
local: List<Manga>,
) {
if (local.isEmpty()) {
return
}
destination += ShelfSectionModel.Local(
items = local.toUi(ListMode.GRID, this),
showAllButtonText = R.string.show_all,
)
}
private suspend fun mapFavourites(
destination: MutableList<in ShelfSectionModel.Favourites>,
favourites: Map<FavouriteCategory, List<Manga>>,
) {
if (favourites.isEmpty()) {
return
}
for ((category, list) in favourites) {
if (list.isNotEmpty()) {
destination += ShelfSectionModel.Favourites(
@@ -177,14 +249,4 @@ class ShelfViewModel @Inject constructor(
}
}
}
private fun timeAgo(date: Date): DateTimeAgo {
val diffDays = -date.daysDiff(System.currentTimeMillis())
return when {
diffDays < 1 -> DateTimeAgo.Today
diffDays == 1 -> DateTimeAgo.Yesterday
diffDays <= 3 -> DateTimeAgo.DaysAgo(diffDays)
else -> DateTimeAgo.LongAgo
}
}
}

View File

@@ -6,16 +6,17 @@ import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlin.jvm.internal.Intrinsics
import org.koitharu.kotatsu.base.ui.list.SectionedSelectionController
import org.koitharu.kotatsu.base.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import org.koitharu.kotatsu.list.ui.ItemSizeResolver
import org.koitharu.kotatsu.list.ui.adapter.emptyHintAD
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.shelf.ui.model.ShelfSectionModel
import kotlin.jvm.internal.Intrinsics
class ShelfAdapter(
lifecycleOwner: LifecycleOwner,
@@ -40,6 +41,7 @@ class ShelfAdapter(
)
.addDelegate(loadingStateAD())
.addDelegate(loadingFooterAD())
.addDelegate(emptyHintAD(listener))
.addDelegate(emptyStateListAD(coil, listener))
.addDelegate(errorStateListAD(listener))
}
@@ -56,6 +58,7 @@ class ShelfAdapter(
oldItem is ShelfSectionModel && newItem is ShelfSectionModel -> {
oldItem.key == newItem.key
}
else -> oldItem.javaClass == newItem.javaClass
}
}

View File

@@ -0,0 +1,101 @@
package org.koitharu.kotatsu.shelf.ui.config
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.activity.viewModels
import androidx.core.graphics.Insets
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.base.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityShelfSettingsBinding
@AndroidEntryPoint
class ShelfSettingsActivity :
BaseActivity<ActivityShelfSettingsBinding>(),
View.OnClickListener, ShelfSettingsListener {
private val viewModel by viewModels<ShelfSettingsViewModel>()
private lateinit var reorderHelper: ItemTouchHelper
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityShelfSettingsBinding.inflate(layoutInflater))
supportActionBar?.run {
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(com.google.android.material.R.drawable.abc_ic_clear_material)
}
binding.buttonDone.setOnClickListener(this)
val settingsAdapter = ShelfSettingsAdapter(this)
with(binding.recyclerView) {
setHasFixedSize(true)
adapter = settingsAdapter
reorderHelper = ItemTouchHelper(SectionsReorderCallback()).also {
it.attachToRecyclerView(this)
}
}
viewModel.content.observe(this) { settingsAdapter.items = it }
}
override fun onItemCheckedChanged(item: ShelfSettingsItemModel, isChecked: Boolean) {
viewModel.setItemChecked(item, isChecked)
}
override fun onDragHandleTouch(holder: RecyclerView.ViewHolder) {
reorderHelper.startDrag(holder)
}
override fun onClick(v: View?) {
finishAfterTransition()
}
override fun onWindowInsetsChanged(insets: Insets) {
binding.root.updatePadding(
left = insets.left,
right = insets.right,
)
binding.recyclerView.updatePadding(
bottom = insets.bottom,
)
binding.toolbar.updateLayoutParams<ViewGroup.MarginLayoutParams> {
topMargin = insets.top
}
}
private inner class SectionsReorderCallback : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.DOWN or ItemTouchHelper.UP,
0,
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean = viewHolder.itemViewType == target.itemViewType && viewModel.reorderSections(
viewHolder.bindingAdapterPosition,
target.bindingAdapterPosition,
)
override fun canDropOver(
recyclerView: RecyclerView,
current: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder,
): Boolean = current.itemViewType == target.itemViewType
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
override fun isLongPressDragEnabled() = false
}
companion object {
fun newIntent(context: Context) = Intent(context, ShelfSettingsActivity::class.java)
}
}

View File

@@ -0,0 +1,41 @@
package org.koitharu.kotatsu.shelf.ui.config
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
class ShelfSettingsAdapter(
listener: ShelfSettingsListener,
) : AsyncListDifferDelegationAdapter<ShelfSettingsItemModel>(DiffCallback()) {
init {
delegatesManager.addDelegate(shelfCategoryAD(listener))
.addDelegate(shelfSectionAD(listener))
}
class DiffCallback : DiffUtil.ItemCallback<ShelfSettingsItemModel>() {
override fun areItemsTheSame(oldItem: ShelfSettingsItemModel, newItem: ShelfSettingsItemModel): Boolean {
return when {
oldItem is ShelfSettingsItemModel.Section && newItem is ShelfSettingsItemModel.Section -> {
oldItem.section == newItem.section
}
oldItem is ShelfSettingsItemModel.FavouriteCategory && newItem is ShelfSettingsItemModel.FavouriteCategory -> {
oldItem.id == newItem.id
}
else -> false
}
}
override fun areContentsTheSame(oldItem: ShelfSettingsItemModel, newItem: ShelfSettingsItemModel): Boolean {
return oldItem == newItem
}
override fun getChangePayload(oldItem: ShelfSettingsItemModel, newItem: ShelfSettingsItemModel): Any? {
return if (oldItem.isChecked == newItem.isChecked) {
super.getChangePayload(oldItem, newItem)
} else Unit
}
}
}

View File

@@ -0,0 +1,75 @@
package org.koitharu.kotatsu.shelf.ui.config
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.View
import android.widget.CompoundButton
import androidx.core.view.updatePaddingRelative
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ItemCategoryCheckableMultipleBinding
import org.koitharu.kotatsu.databinding.ItemShelfSectionDraggableBinding
import org.koitharu.kotatsu.shelf.domain.ShelfSection
@SuppressLint("ClickableViewAccessibility")
fun shelfSectionAD(
listener: ShelfSettingsListener,
) =
adapterDelegateViewBinding<ShelfSettingsItemModel.Section, ShelfSettingsItemModel, ItemShelfSectionDraggableBinding>(
{ layoutInflater, parent -> ItemShelfSectionDraggableBinding.inflate(layoutInflater, parent, false) },
) {
val eventListener = object :
View.OnTouchListener,
CompoundButton.OnCheckedChangeListener {
override fun onTouch(v: View?, event: MotionEvent): Boolean {
return if (event.actionMasked == MotionEvent.ACTION_DOWN) {
listener.onDragHandleTouch(this@adapterDelegateViewBinding)
true
} else {
false
}
}
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
listener.onItemCheckedChanged(item, isChecked)
}
}
binding.switchToggle.setOnCheckedChangeListener(eventListener)
binding.imageViewHandle.setOnTouchListener(eventListener)
bind {
binding.textViewTitle.setText(item.section.titleResId)
binding.switchToggle.isChecked = item.isChecked
}
}
fun shelfCategoryAD(
listener: ShelfSettingsListener,
) =
adapterDelegateViewBinding<ShelfSettingsItemModel.FavouriteCategory, ShelfSettingsItemModel, ItemCategoryCheckableMultipleBinding>(
{ layoutInflater, parent -> ItemCategoryCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
) {
itemView.setOnClickListener {
listener.onItemCheckedChanged(item, !item.isChecked)
}
binding.root.updatePaddingRelative(
start = binding.root.paddingStart * 2,
end = binding.root.paddingStart,
)
bind {
binding.root.text = item.title
binding.root.isChecked = item.isChecked
}
}
private val ShelfSection.titleResId: Int
get() = when (this) {
ShelfSection.HISTORY -> R.string.history
ShelfSection.LOCAL -> R.string.local_storage
ShelfSection.UPDATED -> R.string.updated
ShelfSection.FAVORITES -> R.string.favourites
}

View File

@@ -0,0 +1,60 @@
package org.koitharu.kotatsu.shelf.ui.config
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.shelf.domain.ShelfSection
sealed interface ShelfSettingsItemModel : ListModel {
val isChecked: Boolean
class Section(
val section: ShelfSection,
override val isChecked: Boolean,
) : ShelfSettingsItemModel {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Section
if (section != other.section) return false
if (isChecked != other.isChecked) return false
return true
}
override fun hashCode(): Int {
var result = section.hashCode()
result = 31 * result + isChecked.hashCode()
return result
}
}
class FavouriteCategory(
val id: Long,
val title: String,
override val isChecked: Boolean,
) : ShelfSettingsItemModel {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as FavouriteCategory
if (id != other.id) return false
if (title != other.title) return false
if (isChecked != other.isChecked) return false
return true
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + title.hashCode()
result = 31 * result + isChecked.hashCode()
return result
}
}
}

View File

@@ -0,0 +1,10 @@
package org.koitharu.kotatsu.shelf.ui.config
import androidx.recyclerview.widget.RecyclerView
interface ShelfSettingsListener {
fun onItemCheckedChanged(item: ShelfSettingsItemModel, isChecked: Boolean)
fun onDragHandleTouch(holder: RecyclerView.ViewHolder)
}

View File

@@ -0,0 +1,101 @@
package org.koitharu.kotatsu.shelf.ui.config
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.shelf.domain.ShelfSection
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.move
import javax.inject.Inject
@HiltViewModel
class ShelfSettingsViewModel @Inject constructor(
private val favouritesRepository: FavouritesRepository,
private val settings: AppSettings,
) : BaseViewModel() {
val content = combine(
settings.observeAsFlow(AppSettings.KEY_SHELF_SECTIONS) { shelfSections },
favouritesRepository.observeCategories(),
) { sections, categories ->
buildList(sections, categories)
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
private var updateJob: Job? = null
fun setItemChecked(item: ShelfSettingsItemModel, isChecked: Boolean) {
val prevJob = updateJob
updateJob = launchJob(Dispatchers.Default) {
prevJob?.join()
when (item) {
is ShelfSettingsItemModel.FavouriteCategory -> {
favouritesRepository.updateCategory(item.id, isChecked)
}
is ShelfSettingsItemModel.Section -> {
val sections = settings.shelfSections
settings.shelfSections = if (isChecked) {
sections + item.section
} else {
if (sections.size > 1) {
sections - item.section
} else {
return@launchJob
}
}
}
}
}
}
fun reorderSections(oldPos: Int, newPos: Int): Boolean {
val snapshot = content.value?.toMutableList() ?: return false
snapshot.move(oldPos, newPos)
settings.shelfSections = snapshot.sections()
return true
}
private fun buildList(
sections: List<ShelfSection>,
categories: List<FavouriteCategory>
): List<ShelfSettingsItemModel> {
val result = ArrayList<ShelfSettingsItemModel>()
val sectionsList = ShelfSection.values().toMutableList()
for (section in sections) {
sectionsList.remove(section)
result.addSection(section, true, categories)
}
for (section in sectionsList) {
result.addSection(section, false, categories)
}
return result
}
private fun MutableList<in ShelfSettingsItemModel>.addSection(
section: ShelfSection,
isEnabled: Boolean,
favouriteCategories: List<FavouriteCategory>,
) {
add(ShelfSettingsItemModel.Section(section, isEnabled))
if (isEnabled && section == ShelfSection.FAVORITES) {
favouriteCategories.mapTo(this) {
ShelfSettingsItemModel.FavouriteCategory(
id = it.id,
title = it.title,
isChecked = it.isVisibleInLibrary,
)
}
}
}
private fun List<ShelfSettingsItemModel>.sections(): List<ShelfSection> {
return mapNotNull { (it as? ShelfSettingsItemModel.Section)?.takeIf { x -> x.isChecked }?.section }
}
}

View File

@@ -1,32 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.config.categories
import androidx.recyclerview.widget.DiffUtil
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
class ShelfCategoriesConfigAdapter(
listener: OnListItemClickListener<FavouriteCategory>,
) : AsyncListDifferDelegationAdapter<FavouriteCategory>(DiffCallback()) {
init {
delegatesManager.addDelegate(shelfCategoryAD(listener))
}
class DiffCallback : DiffUtil.ItemCallback<FavouriteCategory>() {
override fun areItemsTheSame(oldItem: FavouriteCategory, newItem: FavouriteCategory): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: FavouriteCategory, newItem: FavouriteCategory): Boolean {
return oldItem.isVisibleInLibrary == newItem.isVisibleInLibrary && oldItem.title == newItem.title
}
override fun getChangePayload(oldItem: FavouriteCategory, newItem: FavouriteCategory): Any? {
return if (oldItem.isVisibleInLibrary == newItem.isVisibleInLibrary) {
super.getChangePayload(oldItem, newItem)
} else Unit
}
}
}

View File

@@ -1,54 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.config.categories
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.databinding.SheetBaseBinding
@AndroidEntryPoint
class ShelfCategoriesConfigSheet :
BaseBottomSheet<SheetBaseBinding>(),
OnListItemClickListener<FavouriteCategory>,
View.OnClickListener {
private val viewModel by viewModels<ShelfCategoriesConfigViewModel>()
override fun onInflateView(inflater: LayoutInflater, container: ViewGroup?): SheetBaseBinding {
return SheetBaseBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.headerBar.toolbar.setTitle(R.string.favourites_categories)
binding.buttonDone.isVisible = true
binding.buttonDone.setOnClickListener(this)
val adapter = ShelfCategoriesConfigAdapter(this)
binding.recyclerView.adapter = adapter
viewModel.content.observe(viewLifecycleOwner) { adapter.items = it }
}
override fun onItemClick(item: FavouriteCategory, view: View) {
viewModel.toggleItem(item)
}
override fun onClick(v: View?) {
dismiss()
}
companion object {
private const val TAG = "ShelfCategoriesConfigSheet"
fun show(fm: FragmentManager) = ShelfCategoriesConfigSheet().show(fm, TAG)
}
}

View File

@@ -1,30 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.config.categories
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
@HiltViewModel
class ShelfCategoriesConfigViewModel @Inject constructor(
private val favouritesRepository: FavouritesRepository,
) : BaseViewModel() {
val content = favouritesRepository.observeCategories()
.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, emptyList())
private var updateJob: Job? = null
fun toggleItem(category: FavouriteCategory) {
val prevJob = updateJob
updateJob = launchJob(Dispatchers.Default) {
prevJob?.join()
favouritesRepository.updateCategory(category.id, !category.isVisibleInLibrary)
}
}
}

View File

@@ -1,21 +0,0 @@
package org.koitharu.kotatsu.shelf.ui.config.categories
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.base.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.base.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.databinding.ItemCategoryCheckableMultipleBinding
fun shelfCategoryAD(
listener: OnListItemClickListener<FavouriteCategory>,
) = adapterDelegateViewBinding<FavouriteCategory, FavouriteCategory, ItemCategoryCheckableMultipleBinding>(
{ layoutInflater, parent -> ItemCategoryCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
) {
val eventListener = AdapterDelegateClickListenerAdapter(this, listener)
itemView.setOnClickListener(eventListener)
bind {
binding.root.text = item.title
binding.root.isChecked = item.isVisibleInLibrary
}
}

View File

@@ -8,13 +8,13 @@ import androidx.fragment.app.FragmentManager
import com.google.android.material.slider.LabelFormatter
import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseBottomSheet
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.databinding.SheetShelfSizeBinding
import org.koitharu.kotatsu.utils.ext.setValueRounded
import org.koitharu.kotatsu.utils.progress.IntPercentLabelFormatter
import javax.inject.Inject
@AndroidEntryPoint
class ShelfSizeBottomSheet :
@@ -51,9 +51,10 @@ class ShelfSizeBottomSheet :
}
override fun onClick(v: View) {
val slider = binding.sliderGrid
when (v.id) {
R.id.button_small -> binding.sliderGrid.value -= binding.sliderGrid.stepSize
R.id.button_large -> binding.sliderGrid.value += binding.sliderGrid.stepSize
R.id.button_small -> slider.setValueRounded(slider.value - slider.stepSize)
R.id.button_large -> slider.setValueRounded(slider.value + slider.stepSize)
}
}

View File

@@ -4,30 +4,29 @@ import android.content.res.Resources
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
sealed class ShelfSectionModel(
val items: List<MangaItemModel>,
@StringRes val showAllButtonText: Int,
) : ListModel {
sealed interface ShelfSectionModel : ListModel {
abstract val key: Any
abstract fun getTitle(resources: Resources): CharSequence
val items: List<MangaItemModel>
@get:StringRes
val showAllButtonText: Int
val key: String
fun getTitle(resources: Resources): CharSequence
override fun toString(): String
class History(
items: List<MangaItemModel>,
val timeAgo: DateTimeAgo?,
showAllButtonText: Int,
) : ShelfSectionModel(items, showAllButtonText) {
override val items: List<MangaItemModel>,
override val showAllButtonText: Int,
) : ShelfSectionModel {
override val key: Any
get() = timeAgo?.javaClass ?: this::class.java
override val key = "history"
override fun getTitle(resources: Resources): CharSequence {
return timeAgo?.format(resources) ?: resources.getString(R.string.history)
}
override fun getTitle(resources: Resources) = resources.getString(R.string.history)
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -35,7 +34,6 @@ sealed class ShelfSectionModel(
other as History
if (timeAgo != other.timeAgo) return false
if (showAllButtonText != other.showAllButtonText) return false
if (items != other.items) return false
@@ -44,28 +42,22 @@ sealed class ShelfSectionModel(
override fun hashCode(): Int {
var result = items.hashCode()
result = 31 * result + (timeAgo?.hashCode() ?: 0)
result = 31 * result + showAllButtonText.hashCode()
return result
}
override fun toString(): String {
return "hist_$timeAgo"
}
override fun toString(): String = key
}
class Favourites(
items: List<MangaItemModel>,
override val items: List<MangaItemModel>,
val category: FavouriteCategory,
showAllButtonText: Int,
) : ShelfSectionModel(items, showAllButtonText) {
override val showAllButtonText: Int,
) : ShelfSectionModel {
override val key: Any
get() = category.id
override val key = "fav_${category.id}"
override fun getTitle(resources: Resources): CharSequence {
return category.title
}
override fun getTitle(resources: Resources) = category.title
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -87,8 +79,66 @@ sealed class ShelfSectionModel(
return result
}
override fun toString(): String {
return "fav_${category.id}"
override fun toString(): String = key
}
class Updated(
override val items: List<MangaItemModel>,
override val showAllButtonText: Int,
) : ShelfSectionModel {
override val key = "upd"
override fun getTitle(resources: Resources) = resources.getString(R.string.updated)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Updated
if (items != other.items) return false
if (showAllButtonText != other.showAllButtonText) return false
return true
}
override fun hashCode(): Int {
var result = items.hashCode()
result = 31 * result + showAllButtonText
return result
}
override fun toString(): String = key
}
class Local(
override val items: List<MangaItemModel>,
override val showAllButtonText: Int,
) : ShelfSectionModel {
override val key = "local"
override fun getTitle(resources: Resources) = resources.getString(R.string.local_storage)
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Local
if (items != other.items) return false
if (showAllButtonText != other.showAllButtonText) return false
return true
}
override fun hashCode(): Int {
var result = items.hashCode()
result = 31 * result + showAllButtonText
return result
}
override fun toString(): String = key
}
}

View File

@@ -31,7 +31,7 @@ class SuggestionsViewModel @Inject constructor(
when {
list.isEmpty() -> listOf(
EmptyState(
icon = R.drawable.ic_empty_suggestions,
icon = R.drawable.ic_empty_common,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_suggestion_holder,
actionStringRes = 0,

View File

@@ -5,16 +5,17 @@ import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.Context
import android.os.Bundle
import android.util.ArrayMap
import androidx.collection.ArrayMap
import androidx.room.InvalidationTracker
import androidx.room.withTransaction
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.MangaDatabase
@@ -22,6 +23,9 @@ import org.koitharu.kotatsu.core.db.TABLE_FAVOURITES
import org.koitharu.kotatsu.core.db.TABLE_FAVOURITE_CATEGORIES
import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.utils.ext.processLifecycleScope
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SyncController @Inject constructor(

View File

@@ -1,7 +1,14 @@
package org.koitharu.kotatsu.tracker.data
import androidx.room.*
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.MapInfo
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
@Dao
abstract class TracksDao {
@@ -28,9 +35,17 @@ abstract class TracksDao {
@Query("SELECT chapters_new FROM tracks WHERE manga_id = :mangaId")
abstract fun observeNewChapters(mangaId: Long): Flow<Int?>
@Transaction
@MapInfo(valueColumn = "chapters_new")
@Query("SELECT manga.*, chapters_new FROM tracks LEFT JOIN manga ON manga.manga_id = tracks.manga_id WHERE chapters_new > 0 ORDER BY chapters_new DESC")
abstract fun observeUpdatedManga(): Flow<Map<MangaWithTags, Int>>
@Query("DELETE FROM tracks")
abstract suspend fun clear()
@Query("UPDATE tracks SET chapters_new = 0")
abstract suspend fun clearCounters()
@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract suspend fun insert(entity: TrackEntity): Long

View File

@@ -2,10 +2,9 @@ package org.koitharu.kotatsu.tracker.domain
import androidx.annotation.VisibleForTesting
import androidx.room.withTransaction
import java.util.*
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase
@@ -22,6 +21,8 @@ import org.koitharu.kotatsu.tracker.data.toTrackingLogItem
import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import java.util.Date
import javax.inject.Inject
private const val NO_ID = 0L
@@ -41,6 +42,12 @@ class TrackingRepository @Inject constructor(
return db.tracksDao.observeNewChapters().map { list -> list.count { it > 0 } }
}
fun observeUpdatedManga(): Flow<Map<Manga, Int>> {
return db.tracksDao.observeUpdatedManga()
.map { x -> x.mapKeys { it.key.toManga() } }
.distinctUntilChanged()
}
suspend fun getTracks(mangaList: Collection<Manga>): List<MangaTracking> {
val ids = mangaList.mapToSet { it.id }
val tracks = db.tracksDao.findAll(ids).groupBy { it.mangaId }
@@ -91,6 +98,8 @@ class TrackingRepository @Inject constructor(
suspend fun clearLogs() = db.trackLogsDao.clear()
suspend fun clearCounters() = db.tracksDao.clearCounters()
suspend fun gc() {
db.withTransaction {
db.tracksDao.gc()

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.tracker.ui
package org.koitharu.kotatsu.tracker.ui.feed
import android.os.Bundle
import android.view.LayoutInflater
@@ -10,7 +10,6 @@ import androidx.fragment.app.viewModels
import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
@@ -23,11 +22,12 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.tracker.ui.adapter.FeedAdapter
import org.koitharu.kotatsu.tracker.ui.feed.adapter.FeedAdapter
import org.koitharu.kotatsu.tracker.work.TrackWorker
import org.koitharu.kotatsu.utils.ext.addMenuProvider
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.getThemeColor
import javax.inject.Inject
@AndroidEntryPoint
class FeedFragment :

View File

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.tracker.ui
package org.koitharu.kotatsu.tracker.ui.feed
import android.content.Context
import android.view.Menu
@@ -6,9 +6,9 @@ import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.dialog.CheckBoxAlertDialog
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.tracker.work.TrackWorker
@@ -37,21 +37,26 @@ class FeedMenuProvider(
snackbar.show()
true
}
R.id.action_clear_feed -> {
MaterialAlertDialogBuilder(context)
CheckBoxAlertDialog.Builder(context)
.setTitle(R.string.clear_updates_feed)
.setMessage(R.string.text_clear_updates_feed_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.clear) { _, _ ->
viewModel.clearFeed()
}.show()
.setCheckBoxChecked(true)
.setCheckBoxText(R.string.clear_new_chapters_counters)
.setPositiveButton(R.string.clear) { _, isChecked ->
viewModel.clearFeed(isChecked)
}.create().show()
true
}
R.id.action_settings -> {
val intent = SettingsActivity.newTrackerSettingsIntent(context)
context.startActivity(intent)
true
}
else -> false
}
}

View File

@@ -1,11 +1,7 @@
package org.koitharu.kotatsu.tracker.ui
package org.koitharu.kotatsu.tracker.ui.feed
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.*
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
@@ -17,10 +13,14 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import org.koitharu.kotatsu.tracker.ui.model.toFeedItem
import org.koitharu.kotatsu.tracker.ui.feed.model.toFeedItem
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.asFlowLiveData
import org.koitharu.kotatsu.utils.ext.daysDiff
import java.util.Date
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
private const val PAGE_SIZE = 20
@@ -50,9 +50,12 @@ class FeedViewModel @Inject constructor(
}
}.asFlowLiveData(viewModelScope.coroutineContext + Dispatchers.Default, listOf(LoadingState))
fun clearFeed() {
fun clearFeed(clearCounters: Boolean) {
launchLoadingJob(Dispatchers.Default) {
repository.clearLogs()
if (clearCounters) {
repository.clearCounters()
}
onFeedCleared.postCall(Unit)
}
}

View File

@@ -1,14 +1,20 @@
package org.koitharu.kotatsu.tracker.ui.adapter
package org.koitharu.kotatsu.tracker.ui.feed.adapter
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DiffUtil
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
import kotlin.jvm.internal.Intrinsics
import org.koitharu.kotatsu.core.ui.DateTimeAgo
import org.koitharu.kotatsu.list.ui.adapter.*
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.errorFooterAD
import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.adapter.relatedDateItemAD
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.tracker.ui.model.FeedItem
import org.koitharu.kotatsu.tracker.ui.feed.model.FeedItem
import kotlin.jvm.internal.Intrinsics
class FeedAdapter(
coil: ImageLoader,
@@ -33,9 +39,11 @@ class FeedAdapter(
oldItem is FeedItem && newItem is FeedItem -> {
oldItem.id == newItem.id
}
oldItem is DateTimeAgo && newItem is DateTimeAgo -> {
oldItem == newItem
}
else -> oldItem.javaClass == newItem.javaClass
}

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