Compare commits

...

91 Commits

Author SHA1 Message Date
4db61d3c04 Merge branch 'master' into devel 2024-01-24 12:32:04 +02:00
cd0575a524 Update parsers 2024-01-24 12:28:50 +02:00
6eb2608f88 Disable autofill for protect password fields #702
(cherry picked from commit e7c9d1943d)
2024-01-24 12:16:18 +02:00
39e21ff93c Last read order in favorites #705
(cherry picked from commit b1240e7efa)
2024-01-24 12:16:12 +02:00
5ec2eab6b8 Fix webtoon scroll dispatching
(cherry picked from commit a0a72b1192)
2024-01-24 12:15:41 +02:00
850f6c2f3e Fix pages numbers
(cherry picked from commit 83cb35fe6e)
2024-01-24 12:09:11 +02:00
ec53eb9c70 Incognito mode indicator in reader
(cherry picked from commit db1ddf539c)
2024-01-24 12:08:52 +02:00
cdd76f723f Fix favorites counters
(cherry picked from commit d56fc674ab)
2024-01-24 12:08:17 +02:00
e7c9d1943d Disable autofill for protect password fields #702 2024-01-24 11:56:04 +02:00
b1240e7efa Last read order in favorites #705 2024-01-24 11:53:53 +02:00
a0a72b1192 Fix webtoon scroll dispatching 2024-01-24 11:36:48 +02:00
5d9a59d577 Update parsers and migrate to float chapter numbers 2024-01-22 17:11:00 +02:00
83cb35fe6e Fix pages numbers 2024-01-22 13:13:14 +02:00
0fff53ae47 Readers refactor 2024-01-22 13:00:56 +02:00
a95017a5f0 Update reader mode icons 2024-01-22 12:02:25 +02:00
9251823d9a Vertical reader mode 2024-01-21 11:37:22 +02:00
ce8f87272b Refactor ReadActivity menus 2024-01-21 11:02:53 +02:00
db1ddf539c Incognito mode indicator in reader 2024-01-21 10:10:20 +02:00
d56fc674ab Fix favorites counters 2024-01-20 15:54:15 +02:00
a37e8825b0 All favorites item change payload 2024-01-20 09:31:17 +02:00
Isira Seneviratne
c9fcc0f0f8 Improve signature check 2024-01-20 09:23:33 +02:00
2450544454 Update parsers
(cherry picked from commit da2ad40adf)
2024-01-20 09:22:28 +02:00
f6a510653e Fix import dialog injection
(cherry picked from commit af5716a8ce)
2024-01-20 09:21:43 +02:00
5990da587c Update supported links domains
(cherry picked from commit a98202e15e)
2024-01-20 09:21:40 +02:00
91e3d2f5db Skip unsupported sources in global search
(cherry picked from commit d6887e2d75)
2024-01-20 09:21:35 +02:00
971c683746 Show all favorites on categories screen
(cherry picked from commit 2a5300a634)
2024-01-20 09:21:27 +02:00
15e9aaab26 Fix pages list scrolling
(cherry picked from commit a1120ea709)
2024-01-20 09:21:18 +02:00
da2ad40adf Update parsers 2024-01-20 09:19:29 +02:00
af5716a8ce Fix import dialog injection 2024-01-20 09:16:06 +02:00
a98202e15e Update supported links domains 2024-01-20 09:08:26 +02:00
d6887e2d75 Skip unsupported sources in global search 2024-01-20 09:07:27 +02:00
ba6afd44dd Fix reading time format 2024-01-19 16:37:25 +02:00
0b55c4d037 Add volume headers to chapters list 2024-01-19 16:36:36 +02:00
2a5300a634 Show all favorites on categories screen 2024-01-18 16:16:12 +02:00
59bfa929fd Current branch indicator 2024-01-18 14:54:30 +02:00
c5d88f8700 Refactor reading time estimator 2024-01-18 13:38:25 +02:00
a1120ea709 Fix pages list scrolling 2024-01-18 12:19:34 +02:00
796af6b811 Refactor chapters description 2024-01-18 10:58:11 +02:00
eafd878413 Approximate reading time preview 2024-01-17 22:22:37 +03:00
9baf2bfcd9 Update Material lib 2024-01-17 22:20:13 +03:00
0b4dd5beef Enhance chapter items 2024-01-17 16:09:55 +03:00
12047a85c7 Update parsers 2024-01-17 13:33:55 +02:00
48808f8a7d Improve scrobbling selection 2024-01-17 11:41:03 +02:00
18e573b6b8 Merge remote-tracking branch 'weblate/devel' into devel 2024-01-16 15:37:40 +02:00
c80dc08d6c Fix details activity tabs 2024-01-16 15:30:56 +02:00
61b5b8aa73 Fixes 2024-01-16 15:30:55 +02:00
5e79809326 Support reverse alphabetic order in history and favorties 2024-01-16 15:30:55 +02:00
dcbd7c2117 Fix history sorting 2024-01-16 15:30:55 +02:00
1e134b109a Fix favorites sort order 2024-01-16 15:30:55 +02:00
7f9b6a67af Reduce workers retry frequency 2024-01-16 15:30:55 +02:00
79448bb01d Workaround for ViewPager2 bug 2024-01-16 15:30:54 +02:00
b94f9e4b01 Fix primary navigation restore state 2024-01-16 15:30:54 +02:00
ae8b48d733 Migrate favorites to ViewPager2 2024-01-16 15:30:53 +02:00
313013dccd Mark as completed in history 2024-01-16 15:24:58 +02:00
NA0341
c36d23ec06 Update CONTRIBUTING.md
Improved readability & fixed spelling.
2024-01-16 15:24:41 +02:00
Paramjot singj
ebe71476d1 Translated using Weblate (Punjabi)
Currently translated at 4.1% (23 of 556 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pa/
2024-01-15 19:06:18 +01:00
Paramjot singj
ca2ae9bc83 Translated using Weblate (Hindi)
Currently translated at 22.8% (127 of 556 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/hi/
2024-01-15 19:06:18 +01:00
Alex Georgiou
f2898aba85 Translated using Weblate (Greek)
Currently translated at 99.8% (555 of 556 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/el/
2024-01-15 19:06:18 +01:00
Dimas oppo
1b73f19ae1 Translated using Weblate (Indonesian)
Currently translated at 96.2% (535 of 556 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
2024-01-15 19:06:18 +01:00
Paramjot singj
4b4aea0410 Translated using Weblate (Italian)
Currently translated at 100.0% (7 of 7 strings)

Translation: Kotatsu/plurals
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/it/
2024-01-15 19:06:17 +01:00
Çınar
b9f8e3978a Translated using Weblate (Turkish)
Currently translated at 100.0% (556 of 556 strings)

Translation: Kotatsu/Strings
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
2024-01-15 19:06:17 +01:00
Weblate (bot)
89eed10508 Translations update from Hosted Weblate (#652)
* Translated using Weblate (Greek)

Currently translated at 99.8% (555 of 556 strings)

Translated using Weblate (English)

Currently translated at 100.0% (556 of 556 strings)

Translated using Weblate (Greek)

Currently translated at 45.8% (255 of 556 strings)

Translated using Weblate (Greek)

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (English)

Currently translated at 100.0% (556 of 556 strings)

Co-authored-by: Alex Georgiou <alexandrosgeorgiou35@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/el/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/el/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals

* Translated using Weblate (German)

Currently translated at 99.6% (554 of 556 strings)

Translated using Weblate (English)

Currently translated at 100.0% (556 of 556 strings)

Co-authored-by: Bela K <na0341-dev@posteo.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
Translation: Kotatsu/Strings

* Translated using Weblate (Spanish)

Currently translated at 100.0% (556 of 556 strings)

Translated using Weblate (Spanish)

Currently translated at 99.6% (554 of 556 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings

* Translated using Weblate (French)

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (French)

Currently translated at 96.2% (535 of 556 strings)

Co-authored-by: Quentin VERDIER <quent1.verdier@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.6% (554 of 556 strings)

Co-authored-by: Deivinni Silva <deivinnimds3656@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings

* Translated using Weblate (Serbian)

Currently translated at 99.1% (551 of 556 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings

* Translated using Weblate (Chinese (Simplified))

Currently translated at 99.6% (554 of 556 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings

* Translated using Weblate (Chinese (Simplified))

Currently translated at 99.6% (554 of 556 strings)

Co-authored-by: Judith Smith <i@razuritta.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings

* Translated using Weblate (Malay)

Currently translated at 56.1% (312 of 556 strings)

Translated using Weblate (Malay)

Currently translated at 100.0% (7 of 7 strings)

Added translation using Weblate (Malay)

Added translation using Weblate (Malay)

Co-authored-by: Megat Al Zhahir Daniel <megatalzhahir@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ms/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ms/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (556 of 556 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings

* Translated using Weblate (Arabic)

Currently translated at 51.0% (284 of 556 strings)

Co-authored-by: Khaled <ashen.cv@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings

* Translated using Weblate (Greek)

Currently translated at 99.8% (555 of 556 strings)

Co-authored-by: Theodore Visvikis <theodoros.visvikis@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/el/
Translation: Kotatsu/Strings

* Translated using Weblate (Italian)

Currently translated at 100.0% (556 of 556 strings)

Co-authored-by: Paramjot singj <paramjotsingh2852@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings

* Translated using Weblate (Italian)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: Paper Jack <paperjack@tutanota.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/it/
Translation: Kotatsu/plurals

* Added translation using Weblate (Punjabi (Pakistan))

Added translation using Weblate (Punjabi)

Translated using Weblate (Italian)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: Paramjot singj <paramjotsingh2852@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/it/
Translation: Kotatsu/plurals

* Translated using Weblate (Greek)

Currently translated at 99.8% (555 of 556 strings)

Co-authored-by: Alex Georgiou <alexandrosgeorgiou35@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/el/
Translation: Kotatsu/Strings

---------

Co-authored-by: Alex Georgiou <alexandrosgeorgiou35@gmail.com>
Co-authored-by: Bela K <na0341-dev@posteo.org>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Quentin VERDIER <quent1.verdier@gmail.com>
Co-authored-by: Deivinni Silva <deivinnimds3656@gmail.com>
Co-authored-by: Anon <anonymousprivate76@gmail.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: Judith Smith <i@razuritta.me>
Co-authored-by: Megat Al Zhahir Daniel <megatalzhahir@gmail.com>
Co-authored-by: Khaled <ashen.cv@gmail.com>
Co-authored-by: Theodore Visvikis <theodoros.visvikis@gmail.com>
Co-authored-by: Paramjot singj <paramjotsingh2852@gmail.com>
Co-authored-by: Paper Jack <paperjack@tutanota.com>
2024-01-15 13:47:21 +03:00
Alex Georgiou
799c0910ea Translated using Weblate (Greek)
Currently translated at 99.8% (555 of 556 strings)

Co-authored-by: Alex Georgiou <alexandrosgeorgiou35@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/el/
Translation: Kotatsu/Strings
2024-01-14 18:18:56 +01:00
Paramjot singj
ed6802344a Added translation using Weblate (Punjabi (Pakistan))
Added translation using Weblate (Punjabi)

Translated using Weblate (Italian)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: Paramjot singj <paramjotsingh2852@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/it/
Translation: Kotatsu/plurals
2024-01-14 18:18:56 +01:00
Paper Jack
2d94688742 Translated using Weblate (Italian)
Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: Paper Jack <paperjack@tutanota.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/it/
Translation: Kotatsu/plurals
2024-01-14 18:18:56 +01:00
Paramjot singj
d0b44050f5 Translated using Weblate (Italian)
Currently translated at 100.0% (556 of 556 strings)

Co-authored-by: Paramjot singj <paramjotsingh2852@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/it/
Translation: Kotatsu/Strings
2024-01-14 18:18:56 +01:00
Theodore Visvikis
486ae12d41 Translated using Weblate (Greek)
Currently translated at 99.8% (555 of 556 strings)

Co-authored-by: Theodore Visvikis <theodoros.visvikis@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/el/
Translation: Kotatsu/Strings
2024-01-14 18:18:56 +01:00
Khaled
cb36f085d7 Translated using Weblate (Arabic)
Currently translated at 51.0% (284 of 556 strings)

Co-authored-by: Khaled <ashen.cv@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2024-01-14 18:18:56 +01:00
大王叫我来巡山
5af42e5b6b Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (556 of 556 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-01-14 18:18:56 +01:00
Megat Al Zhahir Daniel
160fa2c001 Translated using Weblate (Malay)
Currently translated at 56.1% (312 of 556 strings)

Translated using Weblate (Malay)

Currently translated at 100.0% (7 of 7 strings)

Added translation using Weblate (Malay)

Added translation using Weblate (Malay)

Co-authored-by: Megat Al Zhahir Daniel <megatalzhahir@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ms/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ms/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-01-14 18:18:55 +01:00
Judith Smith
f9ba87b8cf Translated using Weblate (Chinese (Simplified))
Currently translated at 99.6% (554 of 556 strings)

Co-authored-by: Judith Smith <i@razuritta.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-01-14 18:18:55 +01:00
大王叫我来巡山
ee3cf08545 Translated using Weblate (Chinese (Simplified))
Currently translated at 99.6% (554 of 556 strings)

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-01-14 18:18:55 +01:00
Anon
e0497b357b Translated using Weblate (Serbian)
Currently translated at 99.1% (551 of 556 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-01-14 18:18:55 +01:00
Deivinni Silva
6de08cd4fe Translated using Weblate (Portuguese (Brazil))
Currently translated at 99.6% (554 of 556 strings)

Co-authored-by: Deivinni Silva <deivinnimds3656@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-01-14 18:18:55 +01:00
Quentin VERDIER
4760f1ea35 Translated using Weblate (French)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (French)

Currently translated at 96.2% (535 of 556 strings)

Co-authored-by: Quentin VERDIER <quent1.verdier@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/fr/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-01-14 18:18:55 +01:00
gallegonovato
c64c4643bf Translated using Weblate (Spanish)
Currently translated at 100.0% (556 of 556 strings)

Translated using Weblate (Spanish)

Currently translated at 99.6% (554 of 556 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2024-01-14 18:18:55 +01:00
Bela K
7bfcdb387c Translated using Weblate (German)
Currently translated at 99.6% (554 of 556 strings)

Translated using Weblate (English)

Currently translated at 100.0% (556 of 556 strings)

Co-authored-by: Bela K <na0341-dev@posteo.org>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
Translation: Kotatsu/Strings
2024-01-14 18:18:55 +01:00
Alex Georgiou
503fcf65fb Translated using Weblate (Greek)
Currently translated at 99.8% (555 of 556 strings)

Translated using Weblate (English)

Currently translated at 100.0% (556 of 556 strings)

Translated using Weblate (Greek)

Currently translated at 45.8% (255 of 556 strings)

Translated using Weblate (Greek)

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (English)

Currently translated at 100.0% (556 of 556 strings)

Co-authored-by: Alex Georgiou <alexandrosgeorgiou35@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/el/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/el/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/en/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-01-14 18:18:55 +01:00
54fb79dc98 Fix crash on start app update download 2024-01-12 18:18:04 +02:00
ea4c048029 Update parsers 2024-01-12 18:16:32 +02:00
badc826cd3 Hide search on unsupported sources 2024-01-12 18:15:00 +02:00
CakesTwix
f5ece8124e Translated using Weblate (Ukrainian)
Currently translated at 100.0% (554 of 554 strings)

Co-authored-by: CakesTwix <cakestwix1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-01-11 19:26:30 +02:00
Zero O
accdc41d6c Translated using Weblate (Chinese (Simplified))
Currently translated at 100.0% (554 of 554 strings)

Co-authored-by: Zero O <godarms2010@live.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2024-01-11 19:26:30 +02:00
PacoteB2
819730984e Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (554 of 554 strings)

Co-authored-by: PacoteB2 <gop88662@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
2024-01-11 19:26:30 +02:00
Anton Prevrhal
01c404f9e5 Translated using Weblate (German)
Currently translated at 88.6% (491 of 554 strings)

Co-authored-by: Anton Prevrhal <anton.prevrhal@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/de/
Translation: Kotatsu/Strings
2024-01-11 19:26:30 +02:00
Anon
1fad686733 Translated using Weblate (Serbian)
Currently translated at 99.4% (551 of 554 strings)

Co-authored-by: Anon <anonymousprivate76@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/sr/
Translation: Kotatsu/Strings
2024-01-11 19:26:30 +02:00
Сергій
396be6008d Translated using Weblate (Ukrainian)
Currently translated at 100.0% (554 of 554 strings)

Co-authored-by: Сергій <sergiy.goncharuk.1@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2024-01-11 19:26:30 +02:00
ssantos
42f7846167 Translated using Weblate (Portuguese)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (554 of 554 strings)

Co-authored-by: ssantos <ssantos@web.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2024-01-11 19:26:30 +02:00
Макар Разин
dca56a43ee Translated using Weblate (Russian)
Currently translated at 100.0% (554 of 554 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (554 of 554 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2024-01-11 19:26:30 +02:00
cc91e56e1b Update parsers 2024-01-11 19:08:15 +02:00
627cf73d72 Action to mark manga as completed 2024-01-11 18:53:45 +02:00
153 changed files with 4193 additions and 1822 deletions

View File

@@ -1,11 +1,12 @@
## Kotatsu contribution guidelines
- If you want to fix bug or implement a new feature, that already mention in the [issues](https://github.com/KotatsuApp/Kotatsu/issues), please, assign this issue to you and/or comment about it.
- Whether you have to implement new feature, please, open an issue or discussion regarding it to ensure it will be accepted.
- Translations have to be managed using the [Weblate](https://hosted.weblate.org/engage/kotatsu/) platform.
- In case you want to add a new manga source, refer to the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers).
+ If you want to **fix bugs** or **implement new features** that **already have an [issue card](https://github.com/KotatsuApp/Kotatsu/issues):** please assign this issue to you and/or comment about it.
+ If you want to **implement a new feature:** open an issue or discussion regarding it to ensure it will be accepted.
+ **Translations** have to be managed using the [Weblate](https://hosted.weblate.org/engage/kotatsu/) platform.
+ In case you want to **add a new manga source,** refer to the [parsers repository](https://github.com/KotatsuApp/kotatsu-parsers).
Refactoring or some dev-faces improvements are also might be accepted, however please stick to the following principles:
- Performance matters. In the case of choosing between source code beauty and performance, performance should be a priority.
- Please, do not modify readme and other information files (except for typos).
- Avoid adding new dependencies unless required. APK size is important.
**Refactoring** or some **dev-faces improvements** might also be accepted. However, please stick to the following principles:
+ **Performance matters.** In the case of choosing between source code beauty and performance, performance should be a priority.
+ Please, **do not modify readme and other information files** (except for typos).
+ **Avoid adding new dependencies** unless required. APK size is important.

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 34
versionCode = 612
versionName = '6.6.1'
versionCode = 615
versionName = '6.6.6'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@@ -82,7 +82,7 @@ afterEvaluate {
}
dependencies {
//noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:0e8579017b') {
implementation('com.github.KotatsuApp:kotatsu-parsers:a8f9423307') {
exclude group: 'org.json', module: 'json'
}
@@ -94,17 +94,17 @@ dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.activity:activity-ktx:1.8.2'
implementation 'androidx.fragment:fragment-ktx:1.6.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-service:2.6.2'
implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
implementation 'androidx.lifecycle:lifecycle-process:2.7.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.2'
implementation 'com.google.android.material:material:1.12.0-alpha03'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.7.0'
implementation 'androidx.work:work-runtime:2.9.0'
//noinspection GradleDependency
@@ -141,7 +141,7 @@ dependencies {
compileOnly 'com.google.auto.service:auto-service-annotations:1.1.1'
ksp 'dev.zacsweers.autoservice:auto-service-ksp:1.1.0'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.13'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20231013'

View File

@@ -36,7 +36,7 @@ class KotatsuApp : BaseApp() {
FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder()
.penaltyDeath()
.detectFragmentReuse()
// .detectWrongFragmentContainer() FIXME: migrate to ViewPager2
.detectWrongFragmentContainer()
.detectRetainInstanceUsage()
.detectSetUserVisibleHint()
.detectFragmentTagUsage()

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,6 @@ import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
class BookmarksAdapter(
@@ -32,13 +31,6 @@ class BookmarksAdapter(
}
override fun getSectionText(context: Context, position: Int): CharSequence? {
val list = items
for (i in (0..position).reversed()) {
val item = list.getOrNull(i) ?: continue
if (item is ListHeader) {
return item.getText(context)
}
}
return null
return findHeader(position)?.getText(context)
}
}

View File

@@ -13,6 +13,8 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.mapToSet
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import com.google.android.material.R as materialR
@JvmName("mangaIds")
@@ -113,3 +115,16 @@ val Manga.appUrl: Uri
.appendQueryParameter("name", title)
.appendQueryParameter("url", url)
.build()
private val chaptersNumberFormat = DecimalFormat("#.#").also { f ->
f.decimalFormatSymbols = DecimalFormatSymbols.getInstance().also {
it.decimalSeparator = '.'
}
}
fun MangaChapter.formatNumber(): String? {
if (number <= 0f) {
return null
}
return chaptersNumberFormat.format(number.toDouble())
}

View File

@@ -19,7 +19,8 @@ data class ParcelableChapter(
MangaChapter(
id = parcel.readLong(),
name = parcel.readString().orEmpty(),
number = parcel.readInt(),
number = parcel.readFloat(),
volume = parcel.readInt(),
url = parcel.readString().orEmpty(),
scanlator = parcel.readString(),
uploadDate = parcel.readLong(),
@@ -31,7 +32,8 @@ data class ParcelableChapter(
override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) {
parcel.writeLong(id)
parcel.writeString(name)
parcel.writeInt(number)
parcel.writeFloat(number)
parcel.writeInt(volume)
parcel.writeString(url)
parcel.writeString(scanlator)
parcel.writeLong(uploadDate)

View File

@@ -1,16 +1,9 @@
package org.koitharu.kotatsu.core.os
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.pm.PackageInfoCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.util.byte2HexFormatted
import java.io.ByteArrayInputStream
import java.io.InputStream
import java.security.MessageDigest
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.inject.Inject
import javax.inject.Singleton
@@ -18,29 +11,13 @@ import javax.inject.Singleton
class AppValidator @Inject constructor(
@ApplicationContext private val context: Context,
) {
@Suppress("NewApi")
val isOriginalApp by lazy {
getCertificateSHA1Fingerprint() == CERT_SHA1
val certificates = mapOf(CERT_SHA256.toByteArray() to PackageManager.CERT_INPUT_SHA256)
PackageInfoCompat.hasSignatures(context.packageManager, context.packageName, certificates, false)
}
@Suppress("DEPRECATION")
@SuppressLint("PackageManagerGetSignatures")
private fun getCertificateSHA1Fingerprint(): String? = runCatching {
val packageInfo = context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
val signatures = requireNotNull(packageInfo?.signatures)
val cert: ByteArray = signatures.first().toByteArray()
val input: InputStream = ByteArrayInputStream(cert)
val cf = CertificateFactory.getInstance("X509")
val c = cf.generateCertificate(input) as X509Certificate
val md: MessageDigest = MessageDigest.getInstance("SHA1")
val publicKey: ByteArray = md.digest(c.encoded)
return publicKey.byte2HexFormatted()
}.onFailure { error ->
error.printStackTraceDebug()
}.getOrNull()
private companion object {
private const val CERT_SHA1 = "2C:19:C7:E8:07:61:2B:8E:94:51:1B:FD:72:67:07:64:5D:C2:58:AE"
private const val CERT_SHA256 = "67e15100bb809301783edcb6348fa3bbf83034d91e62868a91053dbd70db3f18"
}
}

View File

@@ -347,9 +347,13 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
set(value) = prefs.edit { putEnumValue(KEY_LOCAL_LIST_ORDER, value) }
var historySortOrder: ListSortOrder
get() = prefs.getEnumValue(KEY_HISTORY_ORDER, ListSortOrder.UPDATED)
get() = prefs.getEnumValue(KEY_HISTORY_ORDER, ListSortOrder.LAST_READ)
set(value) = prefs.edit { putEnumValue(KEY_HISTORY_ORDER, value) }
var allFavoritesSortOrder: ListSortOrder
get() = prefs.getEnumValue(KEY_FAVORITES_ORDER, ListSortOrder.NEWEST)
set(value) = prefs.edit { putEnumValue(KEY_FAVORITES_ORDER, value) }
val isRelatedMangaEnabled: Boolean
get() = prefs.getBoolean(KEY_RELATED_MANGA, true)
@@ -530,6 +534,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_OPTIMIZE = "reader_optimize"
const val KEY_LOCAL_LIST_ORDER = "local_order"
const val KEY_HISTORY_ORDER = "history_order"
const val KEY_FAVORITES_ORDER = "fav_order"
const val KEY_WEBTOON_ZOOM = "webtoon_zoom"
const val KEY_PREFETCH_CONTENT = "prefetch_content"
const val KEY_APP_LOCALE = "app_locale"

View File

@@ -4,7 +4,9 @@ enum class ReaderMode(val id: Int) {
STANDARD(1),
REVERSED(3),
WEBTOON(2);
VERTICAL(4),
WEBTOON(2),
;
companion object {

View File

@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import kotlin.coroutines.suspendCoroutine
@@ -35,4 +36,15 @@ open class BaseListAdapter<T : ListModel> : AsyncListDifferDelegationAdapter<T>(
fun removeListListener(listListener: ListListener<T>) {
differ.removeListListener(listListener)
}
fun findHeader(position: Int): ListHeader? {
val snapshot = items
for (i in (0..position).reversed()) {
val item = snapshot.getOrNull(i) ?: continue
if (item is ListHeader) {
return item
}
}
return null
}
}

View File

@@ -4,8 +4,10 @@ import android.content.Context
import android.util.AttributeSet
import android.view.ViewGroup
import androidx.annotation.AttrRes
import androidx.core.view.ancestors
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import org.koitharu.kotatsu.R
class FastScrollRecyclerView @JvmOverloads constructor(
@@ -15,6 +17,16 @@ class FastScrollRecyclerView @JvmOverloads constructor(
) : RecyclerView(context, attrs, defStyleAttr) {
val fastScroller = FastScroller(context, attrs)
var isVP2BugWorkaroundEnabled = false
set(value) {
field = value
if (value && isAttachedToWindow) {
checkIfInVP2()
} else if (!value) {
applyVP2Workaround = false
}
}
private var applyVP2Workaround = false
var isFastScrollerEnabled: Boolean = true
set(value) {
@@ -43,10 +55,29 @@ class FastScrollRecyclerView @JvmOverloads constructor(
override fun onAttachedToWindow() {
super.onAttachedToWindow()
fastScroller.attachRecyclerView(this)
if (isVP2BugWorkaroundEnabled) {
checkIfInVP2()
}
}
override fun onDetachedFromWindow() {
fastScroller.detachRecyclerView()
super.onDetachedFromWindow()
applyVP2Workaround = false
}
override fun isLayoutRequested(): Boolean {
return if (applyVP2Workaround) false else super.isLayoutRequested()
}
override fun requestLayout() {
super.requestLayout()
if (applyVP2Workaround && parent?.isLayoutRequested == true) {
parent?.requestLayout()
}
}
private fun checkIfInVP2() {
applyVP2Workaround = ancestors.any { it is ViewPager2 } == true
}
}

View File

@@ -0,0 +1,38 @@
package org.koitharu.kotatsu.core.ui.util
import android.view.MenuItem
import android.view.View
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
class PopupMenuMediator(
private val provider: MenuProvider,
) : View.OnLongClickListener, PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener {
override fun onLongClick(v: View): Boolean {
val menu = PopupMenu(v.context, v)
provider.onCreateMenu(menu.menu, menu.menuInflater)
provider.onPrepareMenu(menu.menu)
if (!menu.menu.hasVisibleItems()) {
return false
}
menu.setOnMenuItemClickListener(this)
menu.setOnDismissListener(this)
menu.show()
return true
}
override fun onMenuItemClick(item: MenuItem): Boolean {
return provider.onMenuItemSelected(item)
}
override fun onDismiss(menu: PopupMenu) {
provider.onMenuClosed(menu.menu)
}
fun attach(view: View) {
view.setOnLongClickListener(this)
view.setOnContextClickListenerCompat(this)
}
}

View File

@@ -8,9 +8,11 @@ import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
import androidx.annotation.FloatRange
import androidx.annotation.Px
import androidx.annotation.StyleRes
import androidx.core.content.ContextCompat
import androidx.core.content.res.use
import androidx.core.graphics.ColorUtils
import com.google.android.material.R as materialR
fun Context.getThemeDrawable(
@AttrRes resId: Int,
@@ -75,3 +77,7 @@ fun TypedArray.getDrawableCompat(context: Context, index: Int): Drawable? {
val resId = getResourceId(index, 0)
return if (resId != 0) ContextCompat.getDrawable(context, resId) else null
}
@get:StyleRes
val DIALOG_THEME_CENTERED: Int
inline get() = materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered

View File

@@ -15,7 +15,9 @@ import androidx.core.view.descendants
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.button.MaterialButton
import com.google.android.material.progressindicator.BaseProgressIndicator
import com.google.android.material.slider.Slider
import com.google.android.material.tabs.TabLayout
@@ -161,3 +163,12 @@ val Toolbar.menuView: ActionMenuView?
menu // to call ensureMenu()
return children.firstNotNullOfOrNull { it as? ActionMenuView }
}
fun MaterialButton.setProgressIcon() {
val progressDrawable = CircularProgressDrawable(context)
progressDrawable.strokeWidth = resources.resolveDp(2f)
progressDrawable.setColorSchemeColors(currentTextColor)
progressDrawable.setTintList(textColors)
icon = progressDrawable
progressDrawable.start()
}

View File

@@ -0,0 +1,21 @@
package org.koitharu.kotatsu.details.data
import android.content.res.Resources
import org.koitharu.kotatsu.R
data class ReadingTime(
val minutes: Int,
val hours: Int,
val isContinue: Boolean,
) {
fun format(resources: Resources): String = when {
hours == 0 -> resources.getQuantityString(R.plurals.minutes, minutes, minutes)
minutes == 0 -> resources.getQuantityString(R.plurals.hours, hours, hours)
else -> resources.getString(
R.string.remaining_time_pattern,
resources.getQuantityString(R.plurals.hours, hours, hours),
resources.getQuantityString(R.plurals.minutes, minutes, minutes),
)
}
}

View File

@@ -0,0 +1,33 @@
package org.koitharu.kotatsu.details.domain
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.data.ReadingTime
import javax.inject.Inject
import kotlin.math.roundToInt
class ReadingTimeUseCase @Inject constructor() {
fun invoke(manga: MangaDetails?, branch: String?, history: MangaHistory?): ReadingTime? {
// FIXME MAXIMUM HARDCODE!!! To do calculation with user's page read speed and his favourites/history mangas average pages in chapter
val chapters = manga?.chapters?.get(branch)
if (chapters.isNullOrEmpty()) {
return null
}
val isOnHistoryBranch = history != null && chapters.findById(history.chapterId) != null
// Impossible task, I guess. Good luck on this.
var averageTimeSec: Int = 20 * 10 * chapters.size // 20 pages, 10 seconds per page
if (isOnHistoryBranch) {
averageTimeSec = (averageTimeSec * (1f - checkNotNull(history).percent)).roundToInt()
}
if (averageTimeSec < 60) {
return null
}
return ReadingTime(
minutes = (averageTimeSec / 60) % 60,
hours = averageTimeSec / 3600,
isContinue = isOnHistoryBranch,
)
}
}

View File

@@ -8,12 +8,15 @@ import androidx.activity.OnBackPressedCallback
import androidx.appcompat.view.ActionMode
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.tabs.TabLayout
import org.koitharu.kotatsu.core.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.util.ext.doOnExpansionsChanged
import org.koitharu.kotatsu.core.util.ext.setTabsEnabled
class ChaptersBottomSheetMediator(
private val behavior: BottomSheetBehavior<*>,
private val pager: ViewPager2,
private val tabLayout: TabLayout,
) : OnBackPressedCallback(false),
ActionModeListener,
OnLayoutChangeListener, View.OnGenericMotionListener {
@@ -75,8 +78,7 @@ class ChaptersBottomSheetMediator(
fun lock() {
lockCounter++
behavior.isDraggable = lockCounter <= 0
pager.isUserInputEnabled = lockCounter <= 0
updateLock()
}
fun unlock() {
@@ -84,7 +86,12 @@ class ChaptersBottomSheetMediator(
if (lockCounter < 0) {
lockCounter = 0
}
updateLock()
}
private fun updateLock() {
behavior.isDraggable = lockCounter <= 0
pager.isUserInputEnabled = lockCounter <= 0
tabLayout.setTabsEnabled(lockCounter <= 0)
}
}

View File

@@ -1,10 +1,14 @@
package org.koitharu.kotatsu.details.ui
import android.content.Context
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.util.mapToSet
fun MangaDetails.mapChapters(
@@ -61,3 +65,22 @@ fun MangaDetails.mapChapters(
}
return result
}
fun List<ChapterListItem>.withVolumeHeaders(context: Context): List<ListModel> {
var prevVolume = 0
val result = ArrayList<ListModel>((size * 1.4).toInt())
for (item in this) {
val chapter = item.chapter
if (chapter.volume != prevVolume) {
val text = if (chapter.volume == 0) {
context.getString(R.string.volume_unknown)
} else {
context.getString(R.string.volume_, chapter.volume)
}
result.add(ListHeader(text))
prevVolume = chapter.volume
}
result.add(item)
}
return result
}

View File

@@ -4,7 +4,9 @@ import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.text.style.DynamicDrawableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.ImageSpan
import android.text.style.RelativeSizeSpan
import android.transition.AutoTransition
import android.transition.Slide
@@ -107,7 +109,7 @@ class DetailsActivity :
if (viewBinding.layoutBottom != null) {
val behavior = BottomSheetBehavior.from(checkNotNull(viewBinding.layoutBottom))
val bsMediator = ChaptersBottomSheetMediator(behavior, viewBinding.pager)
val bsMediator = ChaptersBottomSheetMediator(behavior, viewBinding.pager, viewBinding.tabs)
actionModeDelegate.addListener(bsMediator)
checkNotNull(viewBinding.layoutBsHeader).addOnLayoutChangeListener(bsMediator)
onBackPressedDispatcher.addCallback(bsMediator)
@@ -318,6 +320,18 @@ class DetailsActivity :
val branches = viewModel.branches.value
for ((i, branch) in branches.withIndex()) {
val title = buildSpannedString {
if (branch.isCurrent) {
inSpans(
ImageSpan(
this@DetailsActivity,
R.drawable.ic_current_chapter,
DynamicDrawableSpan.ALIGN_BASELINE,
),
) {
append(' ')
}
append(' ')
}
append(branch.name ?: getString(R.string.system_default))
append(' ')
append(' ')
@@ -363,8 +377,9 @@ class DetailsActivity :
}
private fun initPager() {
viewBinding.pager.recyclerView?.isNestedScrollingEnabled = false
val adapter = DetailsPagerAdapter(this)
viewBinding.pager.recyclerView?.isNestedScrollingEnabled = false
viewBinding.pager.offscreenPageLimit = 1
viewBinding.pager.adapter = adapter
TabLayoutMediator(viewBinding.tabs, viewBinding.pager, adapter).attach()
viewBinding.pager.setCurrentItem(settings.defaultDetailsTab, false)

View File

@@ -48,6 +48,7 @@ import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.showOrHide
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.FragmentDetailsBinding
import org.koitharu.kotatsu.details.data.ReadingTime
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity
@@ -70,6 +71,8 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity
import java.text.SimpleDateFormat
import java.util.Date
import javax.inject.Inject
@AndroidEntryPoint
@@ -118,6 +121,7 @@ class DetailsFragment :
viewModel.localSize.observe(viewLifecycleOwner, ::onLocalSizeChanged)
viewModel.relatedManga.observe(viewLifecycleOwner, ::onRelatedMangaChanged)
viewModel.chapters.observe(viewLifecycleOwner, ::onChaptersChanged)
viewModel.readingTime.observe(viewLifecycleOwner, ::onReadingTimeChanged)
}
override fun onItemClick(item: Bookmark, view: View) {
@@ -211,6 +215,19 @@ class DetailsFragment :
}
}
private fun onReadingTimeChanged(time: ReadingTime?) {
val binding = viewBinding ?: return
if (time == null) {
binding.approximateReadTimeLayout.isVisible = false
return
}
binding.approximateReadTime.text = time.format(resources)
binding.approximateReadTimeTitle.setText(
if (time.isContinue) R.string.approximate_remaining_time else R.string.approximate_reading_time
)
binding.approximateReadTimeLayout.isVisible = true
}
private fun onDescriptionChanged(description: CharSequence?) {
val tv = requireViewBinding().textViewDescription
if (description.isNullOrBlank()) {

View File

@@ -25,6 +25,7 @@ import okio.FileNotFoundException
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppSettings
@@ -42,6 +43,7 @@ import org.koitharu.kotatsu.details.domain.BranchComparator
import org.koitharu.kotatsu.details.domain.DetailsInteractor
import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
import org.koitharu.kotatsu.details.domain.ReadingTimeUseCase
import org.koitharu.kotatsu.details.domain.RelatedMangaUseCase
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
@@ -76,6 +78,7 @@ class DetailsViewModel @Inject constructor(
private val extraProvider: ListExtraProvider,
private val detailsLoadUseCase: DetailsLoadUseCase,
private val progressUpdateUseCase: ProgressUpdateUseCase,
private val readingTimeUseCase: ReadingTimeUseCase,
) : BaseViewModel() {
private val intent = MangaIntent(savedStateHandle)
@@ -169,10 +172,21 @@ class DetailsViewModel @Inject constructor(
val branches: StateFlow<List<MangaBranch>> = combine(
details,
selectedBranch,
) { m, b ->
(m?.chapters ?: return@combine emptyList())
.map { x -> MangaBranch(x.key, x.value.size, x.key == b) }
.sortedWith(BranchComparator())
history,
) { m, b, h ->
val c = m?.chapters
if (c.isNullOrEmpty()) {
return@combine emptyList()
}
val currentBranch = h?.let { m.allChapters.findById(it.chapterId) }?.branch
c.map { x ->
MangaBranch(
name = x.key,
count = x.value.size,
isSelected = x.key == b,
isCurrent = h != null && x.key == currentBranch,
)
}.sortedWith(BranchComparator())
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val isChaptersEmpty: StateFlow<Boolean> = details.map {
@@ -200,6 +214,14 @@ class DetailsViewModel @Inject constructor(
(if (reversed) list.asReversed() else list).filterSearch(query)
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
val readingTime = combine(
details,
selectedBranch,
history,
) { m, b, h ->
readingTimeUseCase.invoke(m, b, h)
}.stateIn(viewModelScope, SharingStarted.Lazily, null)
val selectedBranchValue: String?
get() = selectedBranch.value

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.details.ui.adapter
import android.graphics.Typeface
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
@@ -7,15 +8,15 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.drawableStart
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemChapterBinding
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import com.google.android.material.R as materialR
import org.koitharu.kotatsu.list.ui.model.ListModel
fun chapterListItemAD(
clickListener: OnListItemClickListener<ChapterListItem>,
) = adapterDelegateViewBinding<ChapterListItem, ChapterListItem, ItemChapterBinding>(
) = adapterDelegateViewBinding<ChapterListItem, ListModel, ItemChapterBinding>(
{ inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) },
) {
@@ -26,31 +27,38 @@ fun chapterListItemAD(
bind { payloads ->
if (payloads.isEmpty()) {
binding.textViewTitle.text = item.chapter.name
binding.textViewNumber.text = item.chapter.number.toString()
binding.textViewDescription.textAndVisible = item.description()
binding.textViewDescription.textAndVisible = item.description
}
when {
item.isCurrent -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_primary)
binding.textViewNumber.setTextColor(context.getThemeColor(materialR.attr.colorOnPrimary))
binding.textViewTitle.drawableStart = ContextCompat.getDrawable(context, R.drawable.ic_current_chapter)
binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary))
binding.textViewDescription.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary))
binding.textViewTitle.typeface = Typeface.DEFAULT_BOLD
binding.textViewDescription.typeface = Typeface.DEFAULT_BOLD
}
item.isUnread -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_default)
binding.textViewNumber.setTextColor(context.getThemeColor(materialR.attr.colorOnTertiary))
binding.textViewTitle.drawableStart = if (item.isNew) {
ContextCompat.getDrawable(context, R.drawable.ic_new)
} else {
null
}
binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary))
binding.textViewDescription.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary))
binding.textViewTitle.typeface = Typeface.DEFAULT
binding.textViewDescription.typeface = Typeface.DEFAULT
}
else -> {
binding.textViewNumber.setBackgroundResource(R.drawable.bg_badge_outline)
binding.textViewNumber.setTextColor(context.getThemeColor(android.R.attr.textColorTertiary))
binding.textViewTitle.drawableStart = null
binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorHint))
binding.textViewDescription.setTextColor(context.getThemeColorStateList(android.R.attr.textColorHint))
binding.textViewTitle.typeface = Typeface.DEFAULT
binding.textViewDescription.typeface = Typeface.DEFAULT
}
}
binding.imageViewBookmarked.isVisible = item.isBookmarked
binding.imageViewDownloaded.isVisible = item.isDownloaded
binding.textViewTitle.drawableStart = if (item.isNew) {
ContextCompat.getDrawable(context, R.drawable.ic_new)
} else {
null
}
}
}

View File

@@ -5,22 +5,20 @@ import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.model.ListModel
class ChaptersAdapter(
onItemClickListener: OnListItemClickListener<ChapterListItem>,
) : BaseListAdapter<ChapterListItem>(), FastScroller.SectionIndexer {
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
init {
setHasStableIds(true)
delegatesManager.addDelegate(chapterListItemAD(onItemClickListener))
}
override fun getItemId(position: Int): Long {
return items[position].chapter.id
addDelegate(ListItemType.CHAPTER, chapterListItemAD(onItemClickListener))
addDelegate(ListItemType.HEADER, listHeaderAD(null))
}
override fun getSectionText(context: Context, position: Int): CharSequence? {
val item = items.getOrNull(position) ?: return null
return item.chapter.number.toString()
return findHeader(position)?.getText(context)
}
}

View File

@@ -1,6 +1,8 @@
package org.koitharu.kotatsu.details.ui.model
import android.text.format.DateUtils
import org.jsoup.internal.StringUtil.StringJoiner
import org.koitharu.kotatsu.core.model.formatNumber
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.MangaChapter
@@ -10,6 +12,14 @@ data class ChapterListItem(
private val uploadDateMs: Long,
) : ListModel {
var description: String? = null
private set
get() {
if (field != null) return field
field = buildDescription()
return field
}
var uploadDate: CharSequence? = null
private set
get() {
@@ -38,13 +48,20 @@ data class ChapterListItem(
val isNew: Boolean
get() = hasFlag(FLAG_NEW)
fun description(): CharSequence? {
val scanlator = chapter.scanlator?.takeUnless { it.isBlank() }
return when {
uploadDate != null && scanlator != null -> "$uploadDate • $scanlator"
scanlator != null -> scanlator
else -> uploadDate
private fun buildDescription(): String {
val joiner = StringJoiner(" • ")
chapter.formatNumber()?.let {
joiner.add("#").append(it)
}
uploadDate?.let { date ->
joiner.add(date.toString())
}
chapter.scanlator?.let { scanlator ->
if (scanlator.isNotBlank()) {
joiner.add(scanlator)
}
}
return joiner.complete()
}
private fun hasFlag(flag: Int): Boolean {

View File

@@ -7,6 +7,7 @@ data class MangaBranch(
val name: String?,
val count: Int,
val isSelected: Boolean,
val isCurrent: Boolean,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {

View File

@@ -12,6 +12,9 @@ import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
@@ -26,6 +29,9 @@ import org.koitharu.kotatsu.details.ui.DetailsViewModel
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.withVolumeHeaders
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
@@ -57,12 +63,17 @@ class ChaptersFragment :
callback = this,
)
with(binding.recyclerViewChapters) {
addItemDecoration(TypedListSpacingDecoration(context, true))
checkNotNull(selectionController).attachToRecyclerView(this)
setHasFixedSize(true)
isNestedScrollingEnabled = false
adapter = chaptersAdapter
}
viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged)
viewModel.chapters.observe(viewLifecycleOwner, this::onChaptersChanged)
viewModel.chapters
.map { it.withVolumeHeaders(requireContext()) }
.flowOn(Dispatchers.Default)
.observe(viewLifecycleOwner, this::onChaptersChanged)
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
binding.textViewHolder.isVisible = it
}
@@ -83,6 +94,17 @@ class ChaptersFragment :
super.onDestroyView()
}
override fun onPause() {
// required for BottomSheetBehavior
requireViewBinding().recyclerViewChapters.isNestedScrollingEnabled = false
super.onPause()
}
override fun onResume() {
requireViewBinding().recyclerViewChapters.isNestedScrollingEnabled = true
super.onResume()
}
override fun onItemClick(item: ChapterListItem, view: View) {
if (selectionController?.onItemClick(item.chapter.id) == true) {
return
@@ -132,6 +154,9 @@ class ChaptersFragment :
val buffer = HashSet<Long>()
var isAdding = false
for (x in items) {
if (x !is ChapterListItem) {
continue
}
if (x.chapter.id in ids) {
isAdding = true
if (buffer.isNotEmpty()) {
@@ -147,7 +172,13 @@ class ChaptersFragment :
}
R.id.action_select_all -> {
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
val ids = chaptersAdapter?.items?.mapNotNull {
if (it is ChapterListItem) {
it.chapter.id
} else {
null
}
} ?: return false
controller.addAll(ids)
true
}
@@ -171,7 +202,15 @@ class ChaptersFragment :
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
val selectedIds = selectionController?.peekCheckedIds() ?: return false
val allItems = chaptersAdapter?.items.orEmpty()
val items = allItems.withIndex().filter { (_, x) -> x.chapter.id in selectedIds }
val items = allItems.withIndex().mapNotNull<IndexedValue<ListModel>, IndexedValue<ChapterListItem>> { x ->
val value = x.value
@Suppress("UNCHECKED_CAST")
if (value is ChapterListItem && value.chapter.id in selectedIds) {
x as IndexedValue<ChapterListItem>
} else {
null
}
}
var canSave = true
var canDelete = true
items.forEach { (_, x) ->
@@ -200,10 +239,10 @@ class ChaptersFragment :
override fun onWindowInsetsChanged(insets: Insets) = Unit
private fun onChaptersChanged(list: List<ChapterListItem>) {
private fun onChaptersChanged(list: List<ListModel>) {
val adapter = chaptersAdapter ?: return
if (adapter.itemCount == 0) {
val position = list.indexOfFirst { it.isCurrent } - 1
val position = list.indexOfFirst { it is ChapterListItem && it.isCurrent } - 1
if (position > 0) {
val offset = (resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) * 0.6).roundToInt()
adapter.setItems(

View File

@@ -88,6 +88,7 @@ class PagesFragment :
addItemDecoration(TypedListSpacingDecoration(context, false))
adapter = thumbnailsAdapter
setHasFixedSize(true)
isNestedScrollingEnabled = false
addOnLayoutChangeListener(spanResolver)
spanResolver?.setGridSize(settings.gridSize / 100f, this)
addOnScrollListener(ScrollListener().also { scrollListener = it })
@@ -112,6 +113,17 @@ class PagesFragment :
super.onDestroyView()
}
override fun onPause() {
// required for BottomSheetBehavior
requireViewBinding().recyclerView.isNestedScrollingEnabled = false
super.onPause()
}
override fun onResume() {
requireViewBinding().recyclerView.isNestedScrollingEnabled = true
super.onResume()
}
override fun onWindowInsetsChanged(insets: Insets) = Unit
override fun onItemClick(item: PageThumbnail, view: View) {

View File

@@ -7,6 +7,7 @@ import android.view.MenuItem
import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
import org.koitharu.kotatsu.settings.SettingsActivity
class DownloadsMenuProvider(
@@ -41,10 +42,8 @@ class DownloadsMenuProvider(
}
private fun confirmCancelAll() {
MaterialAlertDialogBuilder(
context,
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered,
).setTitle(R.string.cancel_all)
MaterialAlertDialogBuilder(context, DIALOG_THEME_CENTERED)
.setTitle(R.string.cancel_all)
.setMessage(R.string.cancel_all_downloads_confirm)
.setIcon(R.drawable.ic_cancel_multiple)
.setNegativeButton(android.R.string.cancel, null)
@@ -54,10 +53,8 @@ class DownloadsMenuProvider(
}
private fun confirmRemoveCompleted() {
MaterialAlertDialogBuilder(
context,
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered,
).setTitle(R.string.remove_completed)
MaterialAlertDialogBuilder(context, DIALOG_THEME_CENTERED)
.setTitle(R.string.remove_completed)
.setMessage(R.string.remove_completed_downloads_confirm)
.setIcon(R.drawable.ic_clear_all)
.setNegativeButton(android.R.string.cancel, null)

View File

@@ -21,6 +21,7 @@ import kotlinx.coroutines.plus
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.formatNumber
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
@@ -306,7 +307,7 @@ class DownloadsViewModel @Inject constructor(
return chapters.mapNotNullTo(ArrayList(size)) {
if (chapterIds == null || it.id in chapterIds) {
DownloadChapter(
number = it.number,
number = it.formatNumber(),
name = it.name,
isDownloaded = it.id in localChapters,
)

View File

@@ -4,7 +4,7 @@ import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
data class DownloadChapter(
val number: Int,
val number: String?,
val name: String,
val isDownloaded: Boolean,
) : ListModel {

View File

@@ -1,9 +1,7 @@
package org.koitharu.kotatsu.explore.ui.adapter
import android.graphics.Color
import android.view.View
import androidx.lifecycle.LifecycleOwner
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import coil.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
@@ -15,10 +13,9 @@ import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.setProgressIcon
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding
@@ -30,7 +27,6 @@ import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
import org.koitharu.kotatsu.explore.ui.model.RecommendationsItem
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga
import com.google.android.material.R as materialR
fun exploreButtonsAD(
clickListener: View.OnClickListener,
@@ -45,16 +41,7 @@ fun exploreButtonsAD(
bind {
if (item.isRandomLoading) {
val icon = CircularProgressDrawable(context)
icon.strokeWidth = context.resources.resolveDp(2f)
icon.setColorSchemeColors(
context.getThemeColor(
materialR.attr.colorPrimary,
Color.DKGRAY,
),
)
binding.buttonRandom.icon = icon
icon.start()
binding.buttonRandom.setProgressIcon()
} else {
binding.buttonRandom.setIconResource(R.drawable.ic_dice)
}

View File

@@ -95,6 +95,22 @@ abstract class FavouritesDao {
return findCoversImpl(query)
}
suspend fun findCovers(order: ListSortOrder, limit: Int): List<Cover> {
val orderBy = getOrderBy(order)
@Language("RoomSql")
val query = SimpleSQLiteQuery(
"SELECT manga.cover_url AS url, manga.source AS source FROM favourites " +
"LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
"WHERE deleted_at = 0 GROUP BY manga.manga_id ORDER BY $orderBy LIMIT ?",
arrayOf<Any>(limit),
)
return findCoversImpl(query)
}
@Query("SELECT COUNT(DISTINCT manga_id) FROM favourites WHERE deleted_at = 0")
abstract fun observeMangaCount(): Flow<Int>
@Query("SELECT * FROM manga WHERE manga_id IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)")
abstract suspend fun findAllManga(): List<MangaEntity>
@@ -175,9 +191,10 @@ abstract class FavouritesDao {
ListSortOrder.RATING -> "manga.rating DESC"
ListSortOrder.NEWEST -> "favourites.created_at DESC"
ListSortOrder.ALPHABETIC -> "manga.title ASC"
ListSortOrder.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = manga.manga_id) DESC"
ListSortOrder.UPDATED, // for legacy support
ListSortOrder.PROGRESS -> "(SELECT percent FROM history WHERE history.manga_id = manga.manga_id) DESC"
ListSortOrder.ALPHABETIC_REVERSE -> "manga.title DESC"
ListSortOrder.NEW_CHAPTERS -> "IFNULL((SELECT chapters_new FROM tracks WHERE tracks.manga_id = manga.manga_id), 0) DESC"
ListSortOrder.PROGRESS -> "IFNULL((SELECT percent FROM history WHERE history.manga_id = manga.manga_id), 0) DESC"
ListSortOrder.LAST_READ -> "IFNULL((SELECT updated_at FROM history WHERE history.manga_id = manga.manga_id), 0) DESC"
else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
}

View File

@@ -60,6 +60,11 @@ class FavouritesRepository @Inject constructor(
.flatMapLatest { order -> observeAll(categoryId, order) }
}
fun observeMangaCount(): Flow<Int> {
return db.getFavouritesDao().observeMangaCount()
.distinctUntilChanged()
}
fun observeCategories(): Flow<List<FavouriteCategory>> {
return db.getFavouriteCategoriesDao().observeAll().mapItems {
it.toFavouriteCategory()
@@ -89,6 +94,10 @@ class FavouritesRepository @Inject constructor(
}
}
suspend fun getAllFavoritesCovers(order: ListSortOrder, limit: Int): List<Cover> {
return db.getFavouritesDao().findCovers(order, limit)
}
fun observeCategory(id: Long): Flow<FavouriteCategory?> {
return db.getFavouriteCategoriesDao().observe(id)
.map { it?.toFavouriteCategory() }

View File

@@ -7,7 +7,7 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import com.google.android.material.R as materialR
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
class CategoriesSelectionCallback(
private val recyclerView: RecyclerView,
@@ -75,7 +75,7 @@ class CategoriesSelectionCallback(
private fun confirmDeleteCategories(ids: Set<Long>, mode: ActionMode) {
val context = recyclerView.context
MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
MaterialAlertDialogBuilder(context, DIALOG_THEME_CENTERED)
.setMessage(R.string.categories_delete_confirm)
.setTitle(R.string.remove_category)
.setIcon(R.drawable.ic_delete)

View File

@@ -76,7 +76,13 @@ class FavouriteCategoriesActivity :
}
}
override fun onItemClick(item: FavouriteCategory, view: View) {
override fun onItemClick(item: FavouriteCategory?, view: View) {
if (item == null) {
if (selectionController.count == 0) {
startActivity(FavouritesActivity.newIntent(view.context))
}
return
}
if (selectionController.onItemClick(item.id)) {
return
}
@@ -92,8 +98,12 @@ class FavouriteCategoriesActivity :
startActivity(intent)
}
override fun onItemLongClick(item: FavouriteCategory, view: View): Boolean {
return selectionController.onItemLongClick(item.id)
override fun onItemLongClick(item: FavouriteCategory?, view: View): Boolean {
return item != null && selectionController.onItemLongClick(item.id)
}
override fun onShowAllClick(isChecked: Boolean) {
viewModel.setAllCategoriesVisible(isChecked)
}
override fun onDragHandleTouch(holder: RecyclerView.ViewHolder): Boolean {

View File

@@ -5,9 +5,11 @@ import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
interface FavouriteCategoriesListListener : OnListItemClickListener<FavouriteCategory> {
interface FavouriteCategoriesListListener : OnListItemClickListener<FavouriteCategory?> {
fun onDragHandleTouch(holder: RecyclerView.ViewHolder): Boolean
fun onEditClick(item: FavouriteCategory, view: View)
fun onShowAllClick(isChecked: Boolean)
}

View File

@@ -5,17 +5,21 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
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.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.favourites.ui.categories.adapter.AllCategoriesListModel
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -30,9 +34,13 @@ class FavouritesCategoriesViewModel @Inject constructor(
private var commitJob: Job? = null
val content = repository.observeCategoriesWithCovers()
.map { it.toUiList() }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
val content = combine(
repository.observeCategoriesWithCovers(),
observeAllCategories(),
settings.observeAsFlow(AppSettings.KEY_ALL_FAVOURITES_VISIBLE) { isAllFavouritesVisible },
) { cats, all, showAll ->
cats.toUiList(all, showAll)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
fun deleteCategories(ids: Set<Long>) {
launchJob(Dispatchers.Default) {
@@ -74,21 +82,46 @@ class FavouritesCategoriesViewModel @Inject constructor(
}
}
private fun Map<FavouriteCategory, List<Cover>>.toUiList(): List<ListModel> = map { (category, covers) ->
CategoryListModel(
mangaCount = covers.size,
covers = covers.take(3),
category = category,
isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources,
)
}.ifEmpty {
listOf(
EmptyState(
icon = R.drawable.ic_empty_favourites,
textPrimary = R.string.text_empty_holder_primary,
textSecondary = R.string.empty_favourite_categories,
actionStringRes = 0,
private fun Map<FavouriteCategory, List<Cover>>.toUiList(
allFavorites: Pair<Int, List<Cover>>,
showAll: Boolean
): List<ListModel> {
if (isEmpty()) {
return listOf(
EmptyState(
icon = R.drawable.ic_empty_favourites,
textPrimary = R.string.text_empty_holder_primary,
textSecondary = R.string.empty_favourite_categories,
actionStringRes = 0,
),
)
}
val result = ArrayList<ListModel>(size + 1)
result.add(
AllCategoriesListModel(
mangaCount = allFavorites.first,
covers = allFavorites.second,
isVisible = showAll,
),
)
mapTo(result) { (category, covers) ->
CategoryListModel(
mangaCount = covers.size,
covers = covers.take(3),
category = category,
isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources,
)
}
return result
}
private fun observeAllCategories(): Flow<Pair<Int, List<Cover>>> {
return settings.observeAsFlow(AppSettings.KEY_FAVORITES_ORDER) {
allFavoritesSortOrder
}.mapLatest { order ->
repository.getAllFavoritesCovers(order, limit = 3)
}.combine(repository.observeMangaCount()) { covers, count ->
count to covers
}
}
}

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.favourites.ui.categories.adapter
import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
data class AllCategoriesListModel(
val mangaCount: Int,
val covers: List<Cover>,
val isVisible: Boolean,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {
return other is AllCategoriesListModel
}
override fun getChangePayload(previousState: ListModel): Any? {
return if (previousState is AllCategoriesListModel && previousState.isVisible != isVisible) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
super.getChangePayload(previousState)
}
}
}

View File

@@ -19,6 +19,7 @@ class CategoriesAdapter(
init {
addDelegate(ListItemType.CATEGORY_LARGE, categoryAD(coil, lifecycleOwner, onItemClickListener))
addDelegate(ListItemType.NAV_ITEM, allCategoriesAD(coil, lifecycleOwner, onItemClickListener))
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listListener))
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
}

View File

@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemCategoriesAllBinding
import org.koitharu.kotatsu.databinding.ItemCategoryBinding
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -92,3 +93,68 @@ fun categoryAD(
}
}
}
fun allCategoriesAD(
coil: ImageLoader,
lifecycleOwner: LifecycleOwner,
clickListener: FavouriteCategoriesListListener,
) = adapterDelegateViewBinding<AllCategoriesListModel, ListModel, ItemCategoriesAllBinding>(
{ inflater, parent -> ItemCategoriesAllBinding.inflate(inflater, parent, false) },
) {
val eventListener = OnClickListener { v ->
if (v.id == R.id.imageView_visible) {
clickListener.onShowAllClick(!item.isVisible)
} else {
clickListener.onItemClick(null, v)
}
}
val backgroundColor = context.getThemeColor(android.R.attr.colorBackground)
ImageViewCompat.setImageTintList(
binding.imageViewCover3,
ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153)),
)
ImageViewCompat.setImageTintList(
binding.imageViewCover2,
ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 76)),
)
binding.imageViewCover2.backgroundTintList =
ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 76))
binding.imageViewCover3.backgroundTintList =
ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153))
val fallback = ColorDrawable(Color.TRANSPARENT)
val coverViews = arrayOf(binding.imageViewCover1, binding.imageViewCover2, binding.imageViewCover3)
val crossFadeDuration = context.getAnimationDuration(R.integer.config_defaultAnimTime).toInt()
itemView.setOnClickListener(eventListener)
binding.imageViewVisible.setOnClickListener(eventListener)
bind {
binding.textViewSubtitle.text = if (item.mangaCount == 0) {
getString(R.string.empty)
} else {
context.resources.getQuantityString(
R.plurals.items,
item.mangaCount,
item.mangaCount,
)
}
binding.imageViewVisible.setImageResource(
if (item.isVisible) {
R.drawable.ic_eye
} else {
R.drawable.ic_eye_off
},
)
repeat(coverViews.size) { i ->
val cover = item.covers.getOrNull(i)
coverViews[i].newImageRequest(lifecycleOwner, cover?.url)?.run {
placeholder(R.drawable.ic_placeholder)
fallback(fallback)
source(cover?.mangaSource)
crossfade(crossFadeDuration * (i + 1))
error(R.drawable.ic_error_placeholder)
allowRgb565(true)
enqueueWith(coil)
}
}
}
}

View File

@@ -4,7 +4,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
data class FavouriteTabModel(
val id: Long,
val title: String,
val title: String?,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean {

View File

@@ -0,0 +1,53 @@
package org.koitharu.kotatsu.favourites.ui.container
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
class FavouriteTabPopupMenuProvider(
private val context: Context,
private val viewModel: FavouritesContainerViewModel,
private val categoryId: Long
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
val menuResId = if (categoryId == NO_ID) {
R.menu.popup_fav_tab_all
} else {
R.menu.popup_fav_tab
}
menuInflater.inflate(menuResId, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.action_hide -> viewModel.hide(categoryId)
R.id.action_edit -> context.startActivity(
FavouritesCategoryEditActivity.newIntent(context, categoryId),
)
R.id.action_delete -> confirmDelete()
else -> return false
}
return true
}
private fun confirmDelete() {
MaterialAlertDialogBuilder(context, DIALOG_THEME_CENTERED)
.setMessage(R.string.categories_delete_confirm)
.setTitle(R.string.remove_category)
.setIcon(R.drawable.ic_delete)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.remove) { _, _ ->
viewModel.deleteCategory(categoryId)
}.show()
}
}

View File

@@ -1,33 +1,46 @@
package org.koitharu.kotatsu.favourites.ui.container
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentStatePagerAdapter
import androidx.recyclerview.widget.AdapterListUpdateCallback
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.viewpager2.adapter.FragmentStateAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment
import org.koitharu.kotatsu.parsers.util.replaceWith
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import kotlin.coroutines.suspendCoroutine
@Suppress("DEPRECATION")
class FavouritesContainerAdapter(
fm: FragmentManager
) : FragmentStatePagerAdapter(fm, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT),
class FavouritesContainerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment),
FlowCollector<List<FavouriteTabModel>> {
private val dataSet = ArrayList<FavouriteTabModel>()
private val differ = AsyncListDiffer(
AdapterListUpdateCallback(this),
AsyncDifferConfig.Builder(ListModelDiffCallback<FavouriteTabModel>())
.setBackgroundThreadExecutor(Dispatchers.Default.limitedParallelism(2).asExecutor())
.build(),
)
override fun getCount(): Int = dataSet.size
override fun getItemCount(): Int = differ.currentList.size
override fun getItem(position: Int): Fragment {
val item = dataSet[position]
override fun getItemId(position: Int): Long {
return differ.currentList[position].id
}
override fun containsItem(itemId: Long): Boolean {
return differ.currentList.any { x -> x.id == itemId }
}
override fun createFragment(position: Int): Fragment {
val item = differ.currentList[position]
return FavouritesListFragment.newInstance(item.id)
}
override fun getPageTitle(position: Int): CharSequence {
return dataSet[position].title
override suspend fun emit(value: List<FavouriteTabModel>) = suspendCoroutine { cont ->
differ.submitList(value, ContinuationResumeRunnable(cont))
}
override suspend fun emit(value: List<FavouriteTabModel>) {
dataSet.replaceWith(value)
notifyDataSetChanged()
}
fun getItem(position: Int): FavouriteTabModel = differ.currentList[position]
}

View File

@@ -1,55 +0,0 @@
package org.koitharu.kotatsu.favourites.ui.container
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.AdapterListUpdateCallback
import androidx.recyclerview.widget.AsyncDifferConfig
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.core.util.ContinuationResumeRunnable
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import kotlin.coroutines.suspendCoroutine
// FIXME migrate to ViewPager2 in FavouritesContainerFragment
class FavouritesContainerAdapter2(fragment: Fragment) :
FragmentStateAdapter(fragment.childFragmentManager, fragment.viewLifecycleOwner.lifecycle),
TabConfigurationStrategy,
FlowCollector<List<FavouriteTabModel>> {
private val differ = AsyncListDiffer(
AdapterListUpdateCallback(this),
AsyncDifferConfig.Builder(ListModelDiffCallback<FavouriteTabModel>())
.setBackgroundThreadExecutor(Dispatchers.Default.limitedParallelism(2).asExecutor())
.build(),
)
override fun getItemCount(): Int = differ.currentList.size
override fun getItemId(position: Int): Long {
return differ.currentList[position].id
}
override fun containsItem(itemId: Long): Boolean {
return differ.currentList.any { x -> x.id == itemId }
}
override fun createFragment(position: Int): Fragment {
val item = differ.currentList[position]
return FavouritesListFragment.newInstance(item.id)
}
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
val item = differ.currentList[position]
tab.text = item.title
tab.tag = item
}
override suspend fun emit(value: List<FavouriteTabModel>) = suspendCoroutine { cont ->
differ.submitList(value, ContinuationResumeRunnable(cont))
}
}

View File

@@ -12,14 +12,18 @@ import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels
import coil.ImageLoader
import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.setTabsEnabled
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.databinding.FragmentFavouritesContainerBinding
@@ -43,15 +47,21 @@ class FavouritesContainerFragment : BaseFragment<FragmentFavouritesContainerBind
override fun onViewBindingCreated(binding: FragmentFavouritesContainerBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
val adapter = FavouritesContainerAdapter(childFragmentManager)
binding.pager.adapter = adapter
binding.tabs.setupWithViewPager(binding.pager)
val pagerAdapter = FavouritesContainerAdapter(this)
binding.pager.adapter = pagerAdapter
binding.pager.offscreenPageLimit = 1
binding.pager.recyclerView?.isNestedScrollingEnabled = false
TabLayoutMediator(
binding.tabs,
binding.pager,
FavouritesTabConfigurationStrategy(pagerAdapter, viewModel),
).attach()
binding.stubEmpty.setOnInflateListener(this)
actionModeDelegate.addListener(this)
viewModel.categories.observe(viewLifecycleOwner, adapter)
viewModel.categories.observe(viewLifecycleOwner, pagerAdapter)
viewModel.isEmpty.observe(viewLifecycleOwner, ::onEmptyStateChanged)
addMenuProvider(FavouritesContainerMenuProvider(binding.root.context))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.pager))
}
override fun onDestroyView() {

View File

@@ -4,30 +4,80 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
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.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.mapItems
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import javax.inject.Inject
@HiltViewModel
class FavouritesContainerViewModel @Inject constructor(
favouritesRepository: FavouritesRepository,
private val settings: AppSettings,
private val favouritesRepository: FavouritesRepository,
) : BaseViewModel() {
val onActionDone = MutableEventFlow<ReversibleAction>()
private val categoriesStateFlow = favouritesRepository.observeCategoriesForLibrary()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
val categories = categoriesStateFlow.filterNotNull()
.mapItems { FavouriteTabModel(it.id, it.title) }
.distinctUntilChanged()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val categories = combine(
categoriesStateFlow.filterNotNull(),
observeAllFavouritesVisibility(),
) { list, showAll ->
list.toUi(showAll)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val isEmpty = categoriesStateFlow.map {
it?.isEmpty() == true
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
private fun List<FavouriteCategory>.toUi(showAll: Boolean): List<FavouriteTabModel> {
if (isEmpty()) {
return emptyList()
}
val result = ArrayList<FavouriteTabModel>(if (showAll) size + 1 else size)
if (showAll) {
result.add(FavouriteTabModel(NO_ID, null))
}
mapTo(result) { FavouriteTabModel(it.id, it.title) }
return result
}
fun hide(categoryId: Long) {
launchJob(Dispatchers.Default) {
if (categoryId == NO_ID) {
settings.isAllFavouritesVisible = false
} else {
favouritesRepository.updateCategory(categoryId, isVisibleInLibrary = false)
val reverse = ReversibleHandle {
favouritesRepository.updateCategory(categoryId, isVisibleInLibrary = true)
}
onActionDone.call(ReversibleAction(R.string.category_hidden_done, reverse))
}
}
}
fun deleteCategory(categoryId: Long) {
launchJob(Dispatchers.Default) {
favouritesRepository.removeCategories(setOf(categoryId))
}
}
private fun observeAllFavouritesVisibility() = settings.observeAsFlow(
key = AppSettings.KEY_ALL_FAVOURITES_VISIBLE,
valueProducer = { isAllFavouritesVisible },
)
}

View File

@@ -0,0 +1,19 @@
package org.koitharu.kotatsu.favourites.ui.container
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.util.PopupMenuMediator
class FavouritesTabConfigurationStrategy(
private val adapter: FavouritesContainerAdapter,
private val viewModel: FavouritesContainerViewModel,
) : TabConfigurationStrategy {
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
val item = adapter.getItem(position)
tab.text = item.title ?: tab.view.context.getString(R.string.all_favourites)
tab.tag = item
PopupMenuMediator(FavouriteTabPopupMenuProvider(tab.view.context, viewModel, item.id)).attach(tab.view)
}
}

View File

@@ -7,10 +7,10 @@ import android.view.View
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentListBinding
@@ -30,9 +30,7 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
if (viewModel.categoryId != NO_ID) {
addMenuProvider(FavouritesListMenuProvider(binding.root.context, viewModel))
}
binding.recyclerView.isVP2BugWorkaroundEnabled = true
}
override fun onScrolledToEnd() = Unit
@@ -73,6 +71,18 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
true
}
R.id.action_mark_current -> {
MaterialAlertDialogBuilder(context ?: return false)
.setTitle(item.title)
.setMessage(R.string.mark_as_completed_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.markAsRead(selectedItems)
mode.finish()
}.show()
true
}
else -> super.onActionItemClicked(controller, mode, item)
}
}

View File

@@ -1,30 +0,0 @@
package org.koitharu.kotatsu.favourites.ui.list
import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
class FavouritesListMenuProvider(
private val context: Context,
private val viewModel: FavouritesListViewModel,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_favourites, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_edit -> {
context.startActivity(FavouritesCategoryEditActivity.newIntent(context, viewModel.categoryId))
true
}
else -> false
}
}
}

View File

@@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
@@ -21,6 +23,7 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.ui.MangaListViewModel
@@ -28,6 +31,7 @@ import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.list.ui.model.toUi
import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject
@HiltViewModel
@@ -35,31 +39,37 @@ class FavouritesListViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: FavouritesRepository,
private val listExtraProvider: ListExtraProvider,
private val markAsReadUseCase: MarkAsReadUseCase,
settings: AppSettings,
downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler) {
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
private val refreshTrigger = MutableStateFlow(Any())
override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_FAVORITES) { favoritesListMode }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.favoritesListMode)
val sortOrder: StateFlow<ListSortOrder?> = if (categoryId == NO_ID) {
MutableStateFlow(null)
settings.observeAsFlow(AppSettings.KEY_FAVORITES_ORDER) {
allFavoritesSortOrder
}
} else {
repository.observeCategory(categoryId)
.map { it?.order }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
override val content = combine(
if (categoryId == NO_ID) {
repository.observeAll(ListSortOrder.NEWEST)
sortOrder.filterNotNull().flatMapLatest {
repository.observeAll(it)
}
} else {
repository.observeAll(categoryId)
},
listMode,
) { list, mode ->
refreshTrigger,
) { list, mode, _ ->
when {
list.isEmpty() -> listOf(
EmptyState(
@@ -80,10 +90,19 @@ class FavouritesListViewModel @Inject constructor(
emit(listOf(it.toErrorState(canRetry = false)))
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
override fun onRefresh() = Unit
override fun onRefresh() {
refreshTrigger.value = Any()
}
override fun onRetry() = Unit
fun markAsRead(items: Set<Manga>) {
launchLoadingJob(Dispatchers.Default) {
markAsReadUseCase(items)
onRefresh()
}
}
fun removeFromFavourites(ids: Set<Long>) {
if (ids.isEmpty()) {
return

View File

@@ -35,11 +35,12 @@ abstract class HistoryDao {
fun observeAll(order: ListSortOrder): Flow<List<HistoryWithManga>> {
val orderBy = when (order) {
ListSortOrder.UPDATED -> "history.updated_at DESC"
ListSortOrder.LAST_READ -> "history.updated_at DESC"
ListSortOrder.NEWEST -> "history.created_at DESC"
ListSortOrder.PROGRESS -> "history.percent DESC"
ListSortOrder.ALPHABETIC -> "manga.title"
ListSortOrder.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = manga.manga_id) DESC"
ListSortOrder.ALPHABETIC_REVERSE -> "manga.title DESC"
ListSortOrder.NEW_CHAPTERS -> "IFNULL((SELECT chapters_new FROM tracks WHERE tracks.manga_id = manga.manga_id), 0) DESC"
else -> throw IllegalArgumentException("Sort order $order is not supported")
}

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
@@ -161,7 +162,7 @@ class HistoryRepository @Inject constructor(
}
fun shouldSkip(manga: Manga): Boolean {
return manga.isNsfw && settings.isHistoryExcludeNsfw || settings.isIncognitoModeEnabled
return ((manga.source.isNsfw() || manga.isNsfw) && settings.isHistoryExcludeNsfw) || settings.isIncognitoModeEnabled
}
fun observeShouldSkip(manga: Manga): Flow<Boolean> {

View File

@@ -0,0 +1,49 @@
package org.koitharu.kotatsu.history.domain
import dagger.Reusable
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject
@Reusable
class MarkAsReadUseCase @Inject constructor(
private val historyRepository: HistoryRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
) {
suspend operator fun invoke(manga: Manga) {
val repo = mangaRepositoryFactory.create(manga.source)
val details = if (manga.chapters.isNullOrEmpty()) {
repo.getDetails(manga)
} else {
manga
}
val lastChapter = checkNotNull(details.chapters).last()
val pages = repo.getPages(lastChapter)
historyRepository.addOrUpdate(
manga = details,
chapterId = lastChapter.id,
page = pages.lastIndex,
scroll = 0,
percent = 1f,
)
}
suspend operator fun invoke(manga: Collection<Manga>) {
when (manga.size) {
0 -> Unit
1 -> invoke(manga.first())
else -> supervisorScope {
manga.map {
launch {
invoke(it)
}
}.joinAll()
}
}
}
}

View File

@@ -6,7 +6,6 @@ import coil.ImageLoader
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver
class HistoryListAdapter(
@@ -17,13 +16,6 @@ class HistoryListAdapter(
) : MangaListAdapter(coil, lifecycleOwner, listener, sizeResolver), FastScroller.SectionIndexer {
override fun getSectionText(context: Context, position: Int): CharSequence? {
val list = items
for (i in (0..position).reversed()) {
val item = list.getOrNull(i) ?: continue
if (item is ListHeader) {
return item.getText(context)
}
}
return null
return findHeader(position)?.getText(context)
}
}

View File

@@ -5,6 +5,7 @@ import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.view.ActionMode
import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkManageIntent
@@ -54,6 +55,18 @@ class HistoryListFragment : MangaListFragment() {
true
}
R.id.action_mark_current -> {
MaterialAlertDialogBuilder(context ?: return false)
.setTitle(item.title)
.setMessage(R.string.mark_as_completed_prompt)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.markAsRead(selectedItems)
mode.finish()
}.show()
true
}
else -> super.onActionItemClicked(controller, mode, item)
}
}

View File

@@ -8,6 +8,7 @@ import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.dialog.RememberSelectionDialogListener
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
@@ -36,7 +37,7 @@ class HistoryListMenuProvider(
private fun showClearHistoryDialog() {
val selectionListener = RememberSelectionDialogListener(2)
MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
MaterialAlertDialogBuilder(context, DIALOG_THEME_CENTERED)
.setTitle(R.string.clear_history)
.setSingleChoiceItems(
arrayOf(

View File

@@ -25,6 +25,7 @@ import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.onFirst
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase
import org.koitharu.kotatsu.history.domain.model.MangaWithHistory
import org.koitharu.kotatsu.list.domain.ListExtraProvider
import org.koitharu.kotatsu.list.domain.ListSortOrder
@@ -39,6 +40,7 @@ import org.koitharu.kotatsu.list.ui.model.toGridModel
import org.koitharu.kotatsu.list.ui.model.toListDetailedModel
import org.koitharu.kotatsu.list.ui.model.toListModel
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import java.time.Instant
import javax.inject.Inject
@@ -48,6 +50,7 @@ class HistoryListViewModel @Inject constructor(
settings: AppSettings,
private val extraProvider: ListExtraProvider,
private val localMangaRepository: LocalMangaRepository,
private val markAsReadUseCase: MarkAsReadUseCase,
networkState: NetworkState,
downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler) {
@@ -121,6 +124,12 @@ class HistoryListViewModel @Inject constructor(
}
}
fun markAsRead(items: Set<Manga>) {
launchLoadingJob(Dispatchers.Default) {
markAsReadUseCase(items)
}
}
private suspend fun mapList(
list: List<MangaWithHistory>,
grouped: Boolean,
@@ -163,7 +172,7 @@ class HistoryListViewModel @Inject constructor(
}
private fun MangaHistory.header(order: ListSortOrder): ListHeader? = when (order) {
ListSortOrder.UPDATED -> ListHeader(calculateTimeAgo(updatedAt))
ListSortOrder.LAST_READ -> ListHeader(calculateTimeAgo(updatedAt))
ListSortOrder.NEWEST -> ListHeader(calculateTimeAgo(createdAt))
ListSortOrder.PROGRESS -> ListHeader(
when (percent) {
@@ -175,6 +184,7 @@ class HistoryListViewModel @Inject constructor(
)
ListSortOrder.ALPHABETIC,
ListSortOrder.ALPHABETIC_REVERSE,
ListSortOrder.RELEVANCE,
ListSortOrder.NEW_CHAPTERS,
ListSortOrder.RATING -> null

View File

@@ -9,21 +9,22 @@ enum class ListSortOrder(
@StringRes val titleResId: Int,
) {
UPDATED(R.string.updated),
NEWEST(R.string.order_added),
PROGRESS(R.string.progress),
ALPHABETIC(R.string.by_name),
ALPHABETIC_REVERSE(R.string.by_name_reverse),
RATING(R.string.by_rating),
RELEVANCE(R.string.by_relevance),
NEW_CHAPTERS(R.string.new_chapters),
LAST_READ(R.string.last_read),
;
fun isGroupingSupported() = this == UPDATED || this == NEWEST || this == PROGRESS
fun isGroupingSupported() = this == LAST_READ || this == NEWEST || this == PROGRESS
companion object {
val HISTORY: Set<ListSortOrder> = EnumSet.of(UPDATED, NEWEST, PROGRESS, ALPHABETIC, NEW_CHAPTERS)
val FAVORITES: Set<ListSortOrder> = EnumSet.of(ALPHABETIC, NEWEST, RATING, NEW_CHAPTERS, PROGRESS)
val HISTORY: Set<ListSortOrder> = EnumSet.of(LAST_READ, NEWEST, PROGRESS, ALPHABETIC, ALPHABETIC_REVERSE, NEW_CHAPTERS)
val FAVORITES: Set<ListSortOrder> = EnumSet.of(ALPHABETIC, ALPHABETIC_REVERSE, NEWEST, RATING, NEW_CHAPTERS, PROGRESS, LAST_READ)
val SUGGESTIONS: Set<ListSortOrder> = EnumSet.of(RELEVANCE)
operator fun invoke(value: String, fallback: ListSortOrder) = entries.find(value) ?: fallback

View File

@@ -83,4 +83,5 @@ class TypedListSpacingDecoration(
private fun ListItemType?.isEdgeToEdge() = this == ListItemType.MANGA_NESTED_GROUP
|| this == ListItemType.FILTER_SORT
|| this == ListItemType.FILTER_TAG
|| this == ListItemType.CHAPTER
}

View File

@@ -9,6 +9,7 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
@@ -65,7 +66,11 @@ class ListConfigViewModel @Inject constructor(
val value = getSortOrders()?.getOrNull(position) ?: return
when (section) {
is ListConfigSection.Favorites -> launchJob {
favouritesRepository.setCategoryOrder(section.categoryId, value)
if (section.categoryId == NO_ID) {
settings.allFavoritesSortOrder = value
} else {
favouritesRepository.setCategoryOrder(section.categoryId, value)
}
}
ListConfigSection.General -> Unit
@@ -75,9 +80,13 @@ class ListConfigViewModel @Inject constructor(
}
}
private fun getCategorySortOrder(id: Long): ListSortOrder = runBlocking {
private fun getCategorySortOrder(id: Long): ListSortOrder = if (id == NO_ID) {
settings.allFavoritesSortOrder
} else runBlocking {
runCatchingCancellable {
favouritesRepository.getCategory(id).order
}.getOrDefault(ListSortOrder.NEWEST)
}.getOrElse {
settings.allFavoritesSortOrder
}
}
}

View File

@@ -12,6 +12,8 @@ import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.find
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet
@@ -91,6 +93,7 @@ class MangaIndex(source: String?) {
if (!chapters.has(chapter.id.toString())) {
val jo = JSONObject()
jo.put("number", chapter.number)
jo.put("volume", chapter.volume)
jo.put("url", chapter.url)
jo.put("name", chapter.name)
jo.put("uploadDate", chapter.uploadDate)
@@ -162,7 +165,8 @@ class MangaIndex(source: String?) {
id = k.toLong(),
name = v.getString("name"),
url = v.getString("url"),
number = v.getInt("number"),
number = v.getFloatOrDefault("number", 0f),
volume = v.getIntOrDefault("volume", 0),
uploadDate = v.getLongOrDefault("uploadDate", 0L),
scanlator = v.getStringOrNull("scanlator"),
branch = v.getStringOrNull("branch"),

View File

@@ -47,7 +47,7 @@ class SingleMangaImporter @Inject constructor(
val contentResolver = storageManager.contentResolver
val name = contentResolver.resolveName(uri) ?: throw IOException("Cannot fetch name from uri: $uri")
if (!hasCbzExtension(name)) {
throw UnsupportedFileException("Unsupported file on $uri")
throw UnsupportedFileException("Unsupported file $name on $uri")
}
val dest = File(getOutputDir(), name)
runInterruptible {

View File

@@ -71,7 +71,8 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
MangaChapter(
id = "$i${f.name}".longHashCode(),
name = f.nameWithoutExtension.toHumanReadable(),
number = i + 1,
number = 0f,
volume = 0,
source = MangaSource.LOCAL,
uploadDate = f.creationTime,
url = f.toUri().toString(),

View File

@@ -100,6 +100,7 @@ sealed class LocalMangaInput(
id = id,
name = name,
number = number,
volume = volume,
url = url,
scanlator = scanlator,
uploadDate = uploadDate,

View File

@@ -76,7 +76,8 @@ class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
MangaChapter(
id = "$i$s".longHashCode(),
name = s.ifEmpty { title },
number = i + 1,
number = 0f,
volume = 0,
source = MangaSource.LOCAL,
uploadDate = 0L,
url = uriBuilder.fragment(s).build().toString(),

View File

@@ -9,13 +9,20 @@ import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.databinding.DialogImportBinding
import org.koitharu.kotatsu.local.data.LocalStorageManager
import javax.inject.Inject
@AndroidEntryPoint
class ImportDialogFragment : AlertDialogFragment<DialogImportBinding>(), View.OnClickListener {
@Inject
lateinit var storageManager: LocalStorageManager
private val importFileCall = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) {
startImport(it)
}
@@ -55,6 +62,9 @@ class ImportDialogFragment : AlertDialogFragment<DialogImportBinding>(), View.On
if (uris.isEmpty()) {
return
}
uris.forEach {
storageManager.takePermissions(it)
}
val ctx = requireContext()
ImportWorker.start(ctx, uris)
Toast.makeText(ctx, R.string.import_will_start_soon, Toast.LENGTH_LONG).show()

View File

@@ -26,6 +26,15 @@ import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
class LocalListFragment : MangaListFragment(), FilterOwner {
init {
withArgs(1) {
putSerializable(
RemoteListFragment.ARG_SOURCE,
MangaSource.LOCAL,
) // required by FilterCoordinator
}
}
override val viewModel by viewModels<LocalListViewModel>()
override val filter: MangaFilter
@@ -97,14 +106,4 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
Snackbar.LENGTH_SHORT,
).show()
}
companion object {
fun newInstance() = LocalListFragment().withArgs(1) {
putSerializable(
RemoteListFragment.ARG_SOURCE,
MangaSource.LOCAL,
) // required by FilterCoordinator
}
}
}

View File

@@ -134,13 +134,13 @@ class MainNavigationDelegate(
private fun onNavigationItemSelected(@IdRes itemId: Int): Boolean {
return setPrimaryFragment(
when (itemId) {
R.id.nav_history -> HistoryListFragment()
R.id.nav_favorites -> FavouritesContainerFragment()
R.id.nav_explore -> ExploreFragment()
R.id.nav_feed -> FeedFragment()
R.id.nav_local -> LocalListFragment.newInstance()
R.id.nav_suggestions -> SuggestionsFragment()
R.id.nav_bookmarks -> BookmarksFragment()
R.id.nav_history -> HistoryListFragment::class.java
R.id.nav_favorites -> FavouritesContainerFragment::class.java
R.id.nav_explore -> ExploreFragment::class.java
R.id.nav_feed -> FeedFragment::class.java
R.id.nav_local -> LocalListFragment::class.java
R.id.nav_suggestions -> SuggestionsFragment::class.java
R.id.nav_bookmarks -> BookmarksFragment::class.java
else -> return false
},
)
@@ -157,16 +157,17 @@ class MainNavigationDelegate(
else -> 0
}
private fun setPrimaryFragment(fragment: Fragment): Boolean {
if (fragmentManager.isStateSaved) {
private fun setPrimaryFragment(fragmentClass: Class<out Fragment>): Boolean {
if (fragmentManager.isStateSaved || fragmentClass.isInstance(primaryFragment)) {
return false
}
val fragment = instantiateFragment(fragmentClass)
fragment.enterTransition = MaterialFadeThrough()
fragmentManager.beginTransaction()
.setReorderingAllowed(true)
.replace(R.id.container, fragment, TAG_PRIMARY)
.replace(R.id.container, fragmentClass, null, TAG_PRIMARY)
.runOnCommit { onFragmentChanged(fragment, fromUser = true) }
.commit()
onFragmentChanged(fragment, fromUser = true)
return true
}
@@ -182,6 +183,11 @@ class MainNavigationDelegate(
}
}
private fun instantiateFragment(fragmentClass: Class<out Fragment>): Fragment {
val classLoader = navBar.context.classLoader
return fragmentManager.fragmentFactory.instantiate(classLoader, fragmentClass.name)
}
private fun observeSettings(lifecycleOwner: LifecycleOwner) {
settings.observe()
.filter { x -> x == AppSettings.KEY_TRACKER_ENABLED || x == AppSettings.KEY_SUGGESTIONS }

View File

@@ -201,7 +201,7 @@ class PageLoader @Inject constructor(
private suspend fun loadPageImpl(page: MangaPage, progress: MutableStateFlow<Float>): Uri = semaphore.withPermit {
val pageUrl = getPageUrl(page)
check(pageUrl.isNotBlank()) { "Cannot obtain full image url" }
check(pageUrl.isNotBlank()) { "Cannot obtain full image url for $page" }
val uri = Uri.parse(pageUrl)
return if (uri.isZipUri()) {
if (uri.scheme == URI_SCHEME_ZIP) {

View File

@@ -8,6 +8,8 @@ import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
@@ -15,9 +17,13 @@ import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.databinding.SheetChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.mapChapters
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.model.toListItem
import org.koitharu.kotatsu.details.ui.withVolumeHeaders
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.parsers.model.MangaChapter
import java.time.Instant
import javax.inject.Inject
import kotlin.math.roundToInt
@@ -37,32 +43,48 @@ class ChaptersSheet : BaseAdaptiveSheet<SheetChaptersBinding>(),
override fun onViewBindingCreated(binding: SheetChaptersBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
val chapters = viewModel.manga?.allChapters
if (chapters.isNullOrEmpty()) {
val manga = viewModel.manga
if (manga == null) {
dismissAllowingStateLoss()
return
}
val currentId = viewModel.getCurrentState()?.chapterId ?: 0L
val currentPosition = chapters.indexOfFirst { it.id == currentId }
val items = chapters.mapIndexed { index, chapter ->
chapter.toListItem(
isCurrent = index == currentPosition,
isUnread = index > currentPosition,
isNew = false,
isDownloaded = false,
isBookmarked = false,
)
val state = viewModel.getCurrentState()
val currentChapter = state?.let { manga.allChapters.findById(it.chapterId) }
val chapters = manga.mapChapters(
history = state?.let {
MangaHistory(
createdAt = Instant.now(),
updatedAt = Instant.now(),
chapterId = it.chapterId,
page = it.page,
scroll = it.scroll,
percent = PROGRESS_NONE,
)
},
newCount = 0,
branch = currentChapter?.branch,
bookmarks = listOf(),
).withVolumeHeaders(binding.root.context)
if (chapters.isEmpty()) {
dismissAllowingStateLoss()
return
}
val currentPosition = if (currentChapter != null) {
chapters.indexOfFirst { it is ChapterListItem && it.chapter.id == currentChapter.id }
} else {
-1
}
binding.recyclerView.addItemDecoration(TypedListSpacingDecoration(binding.recyclerView.context, true))
binding.recyclerView.adapter = ChaptersAdapter(this).also { adapter ->
if (currentPosition >= 0) {
val targetPosition = (currentPosition - 1).coerceAtLeast(0)
val offset =
(resources.getDimensionPixelSize(R.dimen.chapter_list_item_height) * 0.6).roundToInt()
adapter.setItems(
items, RecyclerViewScrollCallback(binding.recyclerView, targetPosition, offset),
chapters, RecyclerViewScrollCallback(binding.recyclerView, targetPosition, offset),
)
} else {
adapter.items = items
adapter.items = chapters
}
}
}

View File

@@ -10,8 +10,6 @@ import android.transition.TransitionManager
import android.transition.TransitionSet
import android.view.Gravity
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
@@ -40,6 +38,7 @@ import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.ui.BaseFullscreenActivity
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.ui.widgets.ZoomControl
import org.koitharu.kotatsu.core.util.GridTouchHelper
import org.koitharu.kotatsu.core.util.IdlingDetector
@@ -60,8 +59,6 @@ import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.ReaderUiState
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
import org.koitharu.kotatsu.settings.SettingsActivity
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@@ -111,7 +108,6 @@ class ReaderActivity :
touchHelper = GridTouchHelper(this, this)
scrollTimer = scrollTimerFactory.create(this, this)
controlDelegate = ReaderControlDelegate(resources, settings, this, this)
viewBinding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected)
viewBinding.slider.setLabelFormatter(PageLabelFormatter())
viewBinding.zoomControl.listener = this
ReaderSliderListener(this, viewModel).attachToSlider(viewBinding.slider)
@@ -140,10 +136,11 @@ class ReaderActivity :
viewModel.content.observe(this) {
onLoadingStateChanged(viewModel.isLoading.value)
}
viewModel.incognitoMode.observe(this, MenuInvalidator(this))
viewModel.isScreenshotsBlockEnabled.observe(this, this::setWindowSecure)
viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn)
viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged)
viewModel.isBookmarkAdded.observe(this, this::onBookmarkStateChanged)
viewModel.isBookmarkAdded.observe(this, MenuInvalidator(viewBinding.toolbarBottom))
viewModel.onShowToast.observeEvent(this) { msgId ->
Snackbar.make(viewBinding.container, msgId, Snackbar.LENGTH_SHORT)
.setAnchorView(viewBinding.appbarBottom)
@@ -152,6 +149,8 @@ class ReaderActivity :
viewModel.isZoomControlsEnabled.observe(this) {
viewBinding.zoomControl.isVisible = it
}
addMenuProvider(ReaderTopMenuProvider(this, viewModel))
viewBinding.toolbarBottom.addMenuProvider(ReaderBottomMenuProvider(this, readerManager, viewModel))
}
override fun getParentActivityIntent(): Intent? {
@@ -190,50 +189,6 @@ class ReaderActivity :
viewBinding.slider.isRtl = mode == ReaderMode.REVERSED
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.opt_reader_top, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.action_settings -> {
startActivity(SettingsActivity.newReaderSettingsIntent(this))
}
R.id.action_chapters -> {
ChaptersSheet.show(supportFragmentManager)
}
R.id.action_pages_thumbs -> {
val state = viewModel.getCurrentState() ?: return false
PagesThumbnailsSheet.show(
supportFragmentManager,
viewModel.manga?.toManga() ?: return false,
state.chapterId,
state.page,
)
}
R.id.action_bookmark -> {
if (viewModel.isBookmarkAdded.value) {
viewModel.removeBookmark()
} else {
viewModel.addBookmark()
}
}
R.id.action_options -> {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
val currentMode = readerManager.currentMode ?: return false
ReaderConfigSheet.show(supportFragmentManager, currentMode)
}
else -> return super.onOptionsItemSelected(item)
}
return true
}
private fun onLoadingStateChanged(isLoading: Boolean) {
val hasPages = viewModel.content.value.pages.isNotEmpty()
viewBinding.layoutLoading.isVisible = isLoading && !hasPages
@@ -242,9 +197,7 @@ class ReaderActivity :
} else {
viewBinding.toastView.hide()
}
val menu = viewBinding.toolbarBottom.menu
menu.findItem(R.id.action_bookmark).isVisible = hasPages
menu.findItem(R.id.action_pages_thumbs).isVisible = hasPages
viewBinding.toolbarBottom.invalidateMenu()
}
override fun onGridTouch(area: Int) {
@@ -392,12 +345,6 @@ class ReaderActivity :
viewBinding.infoBar.isVisible = isBarEnabled && viewBinding.appbarTop.isGone
}
private fun onBookmarkStateChanged(isAdded: Boolean) {
val menuItem = viewBinding.toolbarBottom.menu.findItem(R.id.action_bookmark) ?: return
menuItem.setTitle(if (isAdded) R.string.bookmark_remove else R.string.bookmark_add)
menuItem.setIcon(if (isAdded) R.drawable.ic_bookmark_added else R.drawable.ic_bookmark)
}
private fun onUiStateChanged(pair: Pair<ReaderUiState?, ReaderUiState?>) {
val (previous: ReaderUiState?, uiState: ReaderUiState?) = pair
title = uiState?.resolveTitle(this) ?: getString(R.string.loading_)

View File

@@ -0,0 +1,74 @@
package org.koitharu.kotatsu.reader.ui
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import androidx.fragment.app.FragmentActivity
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet
import org.koitharu.kotatsu.reader.ui.thumbnails.PagesThumbnailsSheet
import org.koitharu.kotatsu.settings.SettingsActivity
class ReaderBottomMenuProvider(
private val activity: FragmentActivity,
private val readerManager: ReaderManager,
private val viewModel: ReaderViewModel,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_reader_bottom, menu)
onPrepareMenu(menu) // fix, not called in toolbar
}
override fun onPrepareMenu(menu: Menu) {
val hasPages = viewModel.content.value.pages.isNotEmpty()
menu.findItem(R.id.action_pages_thumbs).isVisible = hasPages
val bookmarkItem = menu.findItem(R.id.action_bookmark) ?: return
bookmarkItem.isVisible = hasPages
if (hasPages) {
val hasBookmark = viewModel.isBookmarkAdded.value
bookmarkItem.setTitle(if (hasBookmark) R.string.bookmark_remove else R.string.bookmark_add)
bookmarkItem.setIcon(if (hasBookmark) R.drawable.ic_bookmark_added else R.drawable.ic_bookmark)
}
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_settings -> {
activity.startActivity(SettingsActivity.newReaderSettingsIntent(activity))
true
}
R.id.action_pages_thumbs -> {
val state = viewModel.getCurrentState() ?: return false
PagesThumbnailsSheet.show(
activity.supportFragmentManager,
viewModel.manga?.toManga() ?: return false,
state.chapterId,
state.page,
)
true
}
R.id.action_bookmark -> {
if (viewModel.isBookmarkAdded.value) {
viewModel.removeBookmark()
} else {
viewModel.addBookmark()
}
true
}
R.id.action_options -> {
viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState())
val currentMode = readerManager.currentMode ?: return false
ReaderConfigSheet.show(activity.supportFragmentManager, currentMode)
true
}
else -> false
}
}
}

View File

@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.reversed.ReversedReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.vertical.VerticalReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment
import java.util.EnumMap
@@ -21,6 +22,7 @@ class ReaderManager(
modeMap[ReaderMode.STANDARD] = PagerReaderFragment::class.java
modeMap[ReaderMode.REVERSED] = ReversedReaderFragment::class.java
modeMap[ReaderMode.WEBTOON] = WebtoonReaderFragment::class.java
modeMap[ReaderMode.VERTICAL] = VerticalReaderFragment::class.java
}
val currentReader: BaseReaderFragment<*>?

View File

@@ -0,0 +1,49 @@
package org.koitharu.kotatsu.reader.ui
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import androidx.fragment.app.FragmentActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
class ReaderTopMenuProvider(
private val activity: FragmentActivity,
private val viewModel: ReaderViewModel,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.opt_reader_top, menu)
}
override fun onPrepareMenu(menu: Menu) {
menu.findItem(R.id.action_incognito)?.isVisible = viewModel.incognitoMode.value
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_chapters -> {
ChaptersSheet.show(activity.supportFragmentManager)
true
}
R.id.action_incognito -> {
showIncognitoModeDialog()
true
}
else -> false
}
}
private fun showIncognitoModeDialog() {
MaterialAlertDialogBuilder(activity, DIALOG_THEME_CENTERED)
.setIcon(R.drawable.ic_incognito)
.setTitle(R.string.incognito_mode)
.setMessage(R.string.incognito_mode_hint)
.setPositiveButton(R.string.got_it, null)
.show()
}
}

View File

@@ -51,6 +51,7 @@ import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.history.domain.HistoryUpdateUseCase
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.assertNotNull
import org.koitharu.kotatsu.reader.domain.ChaptersLoader
import org.koitharu.kotatsu.reader.domain.DetectReaderModeUseCase
import org.koitharu.kotatsu.reader.domain.PageLoader
@@ -96,6 +97,12 @@ class ReaderViewModel @Inject constructor(
val onShowToast = MutableEventFlow<Int>()
val uiState = MutableStateFlow<ReaderUiState?>(null)
val incognitoMode = if (isIncognito) {
MutableStateFlow(true)
} else mangaFlow.map {
it != null && historyRepository.shouldSkip(it)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
val content = MutableStateFlow(ReaderContent(emptyList(), null))
val manga: MangaDetails?
get() = mangaData.value
@@ -256,7 +263,7 @@ class ReaderViewModel @Inject constructor(
}
@MainThread
fun onCurrentPageChanged(position: Int) {
fun onCurrentPageChanged(lowerPos: Int, upperPos: Int) {
val prevJob = stateChangeJob
val pages = content.value.pages // capture immediately
stateChangeJob = launchJob(Dispatchers.Default) {
@@ -265,7 +272,8 @@ class ReaderViewModel @Inject constructor(
if (pages.size != content.value.pages.size) {
return@launchJob // TODO
}
pages.getOrNull(position)?.let { page ->
val centerPos = (lowerPos + upperPos) / 2
pages.getOrNull(centerPos)?.let { page ->
currentState.update { cs ->
cs?.copy(chapterId = page.chapterId, page = page.index)
}
@@ -275,14 +283,14 @@ class ReaderViewModel @Inject constructor(
return@launchJob
}
ensureActive()
if (position >= pages.lastIndex - BOUNDS_PAGE_OFFSET) {
if (upperPos >= pages.lastIndex - BOUNDS_PAGE_OFFSET) {
loadPrevNextChapter(pages.last().chapterId, isNext = true)
}
if (position <= BOUNDS_PAGE_OFFSET) {
if (lowerPos <= BOUNDS_PAGE_OFFSET) {
loadPrevNextChapter(pages.first().chapterId, isNext = false)
}
if (pageLoader.isPrefetchApplicable()) {
pageLoader.prefetch(pages.trySublist(position + 1, position + PREFETCH_LIMIT))
pageLoader.prefetch(pages.trySublist(upperPos + 1, upperPos + PREFETCH_LIMIT))
}
}
}
@@ -375,18 +383,20 @@ class ReaderViewModel @Inject constructor(
@WorkerThread
private fun notifyStateChanged() {
val state = getCurrentState()
val chapter = state?.chapterId?.let { chaptersLoader.peekChapter(it) }
val state = getCurrentState().assertNotNull("state") ?: return
val chapter = chaptersLoader.peekChapter(state.chapterId).assertNotNull("chapter") ?: return
val m = manga.assertNotNull("manga") ?: return
val chapterIndex = m.chapters[chapter.branch]?.indexOf(chapter) ?: -1
val newState = ReaderUiState(
mangaName = manga?.toManga()?.title,
branch = chapter?.branch,
chapterName = chapter?.name,
chapterNumber = chapter?.number ?: 0,
chaptersTotal = manga?.chapters?.get(chapter?.branch)?.size ?: 0,
totalPages = if (chapter != null) chaptersLoader.getPagesCount(chapter.id) else 0,
currentPage = state?.page ?: 0,
mangaName = m.toManga().title,
branch = chapter.branch,
chapterName = chapter.name,
chapterNumber = chapterIndex + 1,
chaptersTotal = m.chapters[chapter.branch]?.size ?: 0,
totalPages = chaptersLoader.getPagesCount(chapter.id),
currentPage = state.page,
isSliderEnabled = settings.isReaderSliderEnabled,
percent = if (state != null) computePercent(state.chapterId, state.page) else PROGRESS_NONE,
percent = computePercent(state.chapterId, state.page),
)
uiState.value = newState
}

View File

@@ -79,6 +79,7 @@ class ReaderConfigSheet :
binding.buttonStandard.isChecked = mode == ReaderMode.STANDARD
binding.buttonReversed.isChecked = mode == ReaderMode.REVERSED
binding.buttonWebtoon.isChecked = mode == ReaderMode.WEBTOON
binding.buttonVertical.isChecked = mode == ReaderMode.VERTICAL
binding.checkableGroup.addOnButtonCheckedListener(this)
binding.buttonSavePage.setOnClickListener(this)
@@ -153,6 +154,7 @@ class ReaderConfigSheet :
R.id.button_standard -> ReaderMode.STANDARD
R.id.button_webtoon -> ReaderMode.WEBTOON
R.id.button_reversed -> ReaderMode.REVERSED
R.id.button_vertical -> ReaderMode.VERTICAL
else -> return
}
if (newMode == mode) {

View File

@@ -0,0 +1,189 @@
package org.koitharu.kotatsu.reader.ui.pager
import android.os.Build
import android.os.Bundle
import android.view.InputDevice
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.core.view.children
import androidx.viewpager2.widget.ViewPager2
import androidx.viewpager2.widget.ViewPager2.PageTransformer
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
import org.koitharu.kotatsu.core.ui.list.lifecycle.PagerLifecycleDispatcher
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
import org.koitharu.kotatsu.core.util.ext.findCurrentViewHolder
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.resetTransformations
import org.koitharu.kotatsu.databinding.FragmentReaderPagerBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.standard.NoAnimPageTransformer
import org.koitharu.kotatsu.reader.ui.pager.standard.PageAnimTransformer
import org.koitharu.kotatsu.reader.ui.pager.standard.PageHolder
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerEventSupplier
import org.koitharu.kotatsu.reader.ui.pager.standard.PagesAdapter
import javax.inject.Inject
import kotlin.math.absoluteValue
import kotlin.math.sign
@AndroidEntryPoint
abstract class BasePagerReaderFragment : BaseReaderFragment<FragmentReaderPagerBinding>(),
View.OnGenericMotionListener {
@Inject
lateinit var networkState: NetworkState
@Inject
lateinit var pageLoader: PageLoader
private var pagerLifecycleDispatcher: PagerLifecycleDispatcher? = null
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = FragmentReaderPagerBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(
binding: FragmentReaderPagerBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState)
with(binding.pager) {
onInitPager(this)
doOnPageChanged(::notifyPageChanged)
setOnGenericMotionListener(this@BasePagerReaderFragment)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
recyclerView?.defaultFocusHighlightEnabled = false
}
PagerEventSupplier(this).attach()
pagerLifecycleDispatcher = PagerLifecycleDispatcher(this).also {
registerOnPageChangeCallback(it)
}
adapter = readerAdapter
}
viewModel.pageAnimation.observe(viewLifecycleOwner) {
val transformer = when (it) {
ReaderAnimation.NONE -> NoAnimPageTransformer()
ReaderAnimation.DEFAULT -> null
ReaderAnimation.ADVANCED -> onCreateAdvancedTransformer()
}
binding.pager.setPageTransformer(transformer)
if (transformer == null) {
binding.pager.recyclerView?.children?.forEach { view ->
view.resetTransformations()
}
}
}
}
override fun onDestroyView() {
pagerLifecycleDispatcher = null
requireViewBinding().pager.adapter = null
super.onDestroyView()
}
override fun onZoomIn() {
(viewBinding?.pager?.findCurrentViewHolder() as? PageHolder)?.onZoomIn()
}
override fun onZoomOut() {
(viewBinding?.pager?.findCurrentViewHolder() as? PageHolder)?.onZoomOut()
}
override fun onGenericMotion(v: View?, event: MotionEvent): Boolean {
if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
if (event.actionMasked == MotionEvent.ACTION_SCROLL) {
val axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
val withCtrl = event.metaState and KeyEvent.META_CTRL_MASK != 0
if (!withCtrl) {
switchPageBy(-axisValue.sign.toInt())
return true
}
}
}
return false
}
override suspend fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) = coroutineScope {
val items = launch {
requireAdapter().setItems(pages)
yield()
pagerLifecycleDispatcher?.invalidate()
}
if (pendingState != null) {
val position = pages.indexOfFirst {
it.chapterId == pendingState.chapterId && it.index == pendingState.page
}
items.join()
if (position != -1) {
requireViewBinding().pager.setCurrentItem(position, false)
notifyPageChanged(position)
} else {
Snackbar.make(requireView(), R.string.not_found_404, Snackbar.LENGTH_SHORT)
.show()
}
} else {
items.join()
}
}
override fun onCreateAdapter(): BaseReaderAdapter<*> = PagesAdapter(
lifecycleOwner = viewLifecycleOwner,
loader = pageLoader,
settings = viewModel.readerSettings,
networkState = networkState,
exceptionResolver = exceptionResolver,
)
override fun switchPageBy(delta: Int) {
with(requireViewBinding().pager) {
setCurrentItem(currentItem + delta, isAnimationEnabled())
}
}
override fun switchPageTo(position: Int, smooth: Boolean) {
with(requireViewBinding().pager) {
setCurrentItem(
position,
smooth && isAnimationEnabled() && (currentItem - position).absoluteValue < SMOOTH_SCROLL_LIMIT,
)
}
}
override fun getCurrentState(): ReaderState? = viewBinding?.run {
val adapter = pager.adapter as? BaseReaderAdapter<*>
val page = adapter?.getItemOrNull(pager.currentItem) ?: return@run null
ReaderState(
chapterId = page.chapterId,
page = page.index,
scroll = 0,
)
}
protected open fun onCreateAdvancedTransformer(): PageTransformer = PageAnimTransformer()
protected open fun onInitPager(pager: ViewPager2) {
pager.offscreenPageLimit = 2
}
protected open fun notifyPageChanged(page: Int) {
viewModel.onCurrentPageChanged(page, page)
}
companion object {
const val SMOOTH_SCROLL_LIMIT = 3
}
}

View File

@@ -1,109 +1,15 @@
package org.koitharu.kotatsu.reader.ui.pager.reversed
import android.os.Build
import android.os.Bundle
import android.view.InputDevice
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.core.view.children
import com.google.android.material.snackbar.Snackbar
import androidx.viewpager2.widget.ViewPager2
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
import org.koitharu.kotatsu.core.ui.list.lifecycle.PagerLifecycleDispatcher
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
import org.koitharu.kotatsu.core.util.ext.findCurrentViewHolder
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.resetTransformations
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.BasePagerReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.standard.NoAnimPageTransformer
import org.koitharu.kotatsu.reader.ui.pager.standard.PageHolder
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerEventSupplier
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
import javax.inject.Inject
import kotlin.math.absoluteValue
import kotlin.math.sign
@AndroidEntryPoint
class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>(),
View.OnGenericMotionListener {
class ReversedReaderFragment : BasePagerReaderFragment() {
@Inject
lateinit var networkState: NetworkState
@Inject
lateinit var pageLoader: PageLoader
private var pagerLifecycleDispatcher: PagerLifecycleDispatcher? = null
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = FragmentReaderStandardBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: FragmentReaderStandardBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
with(binding.pager) {
adapter = readerAdapter
offscreenPageLimit = 2
doOnPageChanged(::notifyPageChanged)
setOnGenericMotionListener(this@ReversedReaderFragment)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
recyclerView?.defaultFocusHighlightEnabled = false
}
PagerEventSupplier(this).attach()
pagerLifecycleDispatcher = PagerLifecycleDispatcher(this).also {
registerOnPageChangeCallback(it)
}
}
viewModel.pageAnimation.observe(viewLifecycleOwner) {
val transformer = when (it) {
ReaderAnimation.NONE -> NoAnimPageTransformer()
ReaderAnimation.DEFAULT -> null
ReaderAnimation.ADVANCED -> ReversedPageAnimTransformer()
}
binding.pager.setPageTransformer(transformer)
if (transformer == null) {
binding.pager.recyclerView?.children?.forEach { v ->
v.resetTransformations()
}
}
}
}
override fun onDestroyView() {
pagerLifecycleDispatcher = null
requireViewBinding().pager.adapter = null
super.onDestroyView()
}
override fun onGenericMotion(v: View?, event: MotionEvent): Boolean {
if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
if (event.actionMasked == MotionEvent.ACTION_SCROLL) {
val axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
val withCtrl = event.metaState and KeyEvent.META_CTRL_MASK != 0
if (!withCtrl) {
switchPageBy(-axisValue.sign.toInt())
return true
}
}
}
return false
}
override fun onCreateAdvancedTransformer(): ViewPager2.PageTransformer = ReversedPageAnimTransformer()
override fun onCreateAdapter() = ReversedPagesAdapter(
lifecycleOwner = viewLifecycleOwner,
@@ -113,66 +19,21 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
exceptionResolver = exceptionResolver,
)
override fun onZoomIn() {
(viewBinding?.pager?.findCurrentViewHolder() as? PageHolder)?.onZoomIn()
}
override fun onZoomOut() {
(viewBinding?.pager?.findCurrentViewHolder() as? PageHolder)?.onZoomOut()
}
override fun switchPageBy(delta: Int) {
with(requireViewBinding().pager) {
setCurrentItem(currentItem - delta, isAnimationEnabled())
}
super.switchPageBy(-delta)
}
override fun switchPageTo(position: Int, smooth: Boolean) {
with(requireViewBinding().pager) {
setCurrentItem(
reversed(position),
smooth && isAnimationEnabled() && (currentItem - position).absoluteValue < PagerReaderFragment.SMOOTH_SCROLL_LIMIT,
)
}
super.switchPageTo(reversed(position), smooth)
}
override suspend fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) = coroutineScope {
val reversedPages = pages.asReversed()
val items = launch {
requireAdapter().setItems(reversedPages)
yield()
pagerLifecycleDispatcher?.invalidate()
}
if (pendingState != null) {
val position = reversedPages.indexOfLast {
it.chapterId == pendingState.chapterId && it.index == pendingState.page
}
items.join()
if (position != -1) {
requireViewBinding().pager.setCurrentItem(position, false)
notifyPageChanged(position)
} else {
Snackbar.make(requireView(), R.string.not_found_404, Snackbar.LENGTH_SHORT)
.show()
}
} else {
items.join()
}
override suspend fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) {
super.onPagesChanged(pages.reversed(), pendingState)
}
override fun getCurrentState(): ReaderState? = viewBinding?.run {
val adapter = pager.adapter as? BaseReaderAdapter<*>
val page = adapter?.getItemOrNull(pager.currentItem) ?: return@run null
ReaderState(
chapterId = page.chapterId,
page = page.index,
scroll = 0,
)
}
private fun notifyPageChanged(page: Int) {
viewModel.onCurrentPageChanged(reversed(page))
override fun notifyPageChanged(page: Int) {
val pos = reversed(page)
viewModel.onCurrentPageChanged(pos, pos)
}
private fun reversed(position: Int): Int {

View File

@@ -42,7 +42,6 @@ open class PageHolder(
bindingInfo.buttonRetry.setOnClickListener(this)
@Suppress("LeakingThis")
bindingInfo.buttonErrorDetails.setOnClickListener(this)
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
}
override fun onResume() {
@@ -61,6 +60,7 @@ open class PageHolder(
delegate.reload()
}
binding.ssiv.applyDownsampling(isResumed())
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
}
@SuppressLint("SetTextI18n")

View File

@@ -1,180 +1,7 @@
package org.koitharu.kotatsu.reader.ui.pager.standard
import android.os.Build
import android.os.Bundle
import android.view.InputDevice
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.core.view.children
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.prefs.ReaderAnimation
import org.koitharu.kotatsu.core.ui.list.lifecycle.PagerLifecycleDispatcher
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
import org.koitharu.kotatsu.core.util.ext.findCurrentViewHolder
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.recyclerView
import org.koitharu.kotatsu.core.util.ext.resetTransformations
import org.koitharu.kotatsu.databinding.FragmentReaderStandardBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import javax.inject.Inject
import kotlin.math.absoluteValue
import kotlin.math.sign
import org.koitharu.kotatsu.reader.ui.pager.BasePagerReaderFragment
@AndroidEntryPoint
class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>(),
View.OnGenericMotionListener {
@Inject
lateinit var networkState: NetworkState
@Inject
lateinit var pageLoader: PageLoader
private var pagerLifecycleDispatcher: PagerLifecycleDispatcher? = null
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = FragmentReaderStandardBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(
binding: FragmentReaderStandardBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState)
with(binding.pager) {
adapter = readerAdapter
offscreenPageLimit = 2
doOnPageChanged(::notifyPageChanged)
setOnGenericMotionListener(this@PagerReaderFragment)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
recyclerView?.defaultFocusHighlightEnabled = false
}
PagerEventSupplier(this).attach()
pagerLifecycleDispatcher = PagerLifecycleDispatcher(this).also {
registerOnPageChangeCallback(it)
}
}
viewModel.pageAnimation.observe(viewLifecycleOwner) {
val transformer = when (it) {
ReaderAnimation.NONE -> NoAnimPageTransformer()
ReaderAnimation.DEFAULT -> null
ReaderAnimation.ADVANCED -> PageAnimTransformer()
}
binding.pager.setPageTransformer(transformer)
if (transformer == null) {
binding.pager.recyclerView?.children?.forEach { view ->
view.resetTransformations()
}
}
}
}
override fun onDestroyView() {
pagerLifecycleDispatcher = null
requireViewBinding().pager.adapter = null
super.onDestroyView()
}
override fun onZoomIn() {
(viewBinding?.pager?.findCurrentViewHolder() as? PageHolder)?.onZoomIn()
}
override fun onZoomOut() {
(viewBinding?.pager?.findCurrentViewHolder() as? PageHolder)?.onZoomOut()
}
override fun onGenericMotion(v: View?, event: MotionEvent): Boolean {
if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
if (event.actionMasked == MotionEvent.ACTION_SCROLL) {
val axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
val withCtrl = event.metaState and KeyEvent.META_CTRL_MASK != 0
if (!withCtrl) {
switchPageBy(-axisValue.sign.toInt())
return true
}
}
}
return false
}
override suspend fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) =
coroutineScope {
val items = launch {
requireAdapter().setItems(pages)
yield()
pagerLifecycleDispatcher?.invalidate()
}
if (pendingState != null) {
val position = pages.indexOfFirst {
it.chapterId == pendingState.chapterId && it.index == pendingState.page
}
items.join()
if (position != -1) {
requireViewBinding().pager.setCurrentItem(position, false)
notifyPageChanged(position)
} else {
Snackbar.make(requireView(), R.string.not_found_404, Snackbar.LENGTH_SHORT)
.show()
}
} else {
items.join()
}
}
override fun onCreateAdapter() = PagesAdapter(
lifecycleOwner = viewLifecycleOwner,
loader = pageLoader,
settings = viewModel.readerSettings,
networkState = networkState,
exceptionResolver = exceptionResolver,
)
override fun switchPageBy(delta: Int) {
with(requireViewBinding().pager) {
setCurrentItem(currentItem + delta, isAnimationEnabled())
}
}
override fun switchPageTo(position: Int, smooth: Boolean) {
with(requireViewBinding().pager) {
setCurrentItem(
position,
smooth && isAnimationEnabled() && (currentItem - position).absoluteValue < SMOOTH_SCROLL_LIMIT,
)
}
}
override fun getCurrentState(): ReaderState? = viewBinding?.run {
val adapter = pager.adapter as? BaseReaderAdapter<*>
val page = adapter?.getItemOrNull(pager.currentItem) ?: return@run null
ReaderState(
chapterId = page.chapterId,
page = page.index,
scroll = 0,
)
}
private fun notifyPageChanged(page: Int) {
viewModel.onCurrentPageChanged(page)
}
companion object {
const val SMOOTH_SCROLL_LIMIT = 3
}
}
class PagerReaderFragment : BasePagerReaderFragment()

View File

@@ -0,0 +1,31 @@
package org.koitharu.kotatsu.reader.ui.pager.vertical
import android.view.View
import androidx.viewpager2.widget.ViewPager2
class VerticalPageAnimTransformer : ViewPager2.PageTransformer {
override fun transformPage(page: View, position: Float) = with(page) {
translationY = -position * height
pivotX = width / 2f
pivotY = height * 0.2f
cameraDistance = 20000f
when {
position < -1f || position > 1f -> {
alpha = 0f
rotationX = 0f
translationZ = -1f
}
position > 0f -> {
alpha = 1f
rotationX = 0f
translationZ = 0f
}
position <= 0f -> {
alpha = 1f
rotationX = -120 * position
translationZ = 2f
}
}
}
}

View File

@@ -0,0 +1,16 @@
package org.koitharu.kotatsu.reader.ui.pager.vertical
import androidx.viewpager2.widget.ViewPager2
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.reader.ui.pager.BasePagerReaderFragment
@AndroidEntryPoint
class VerticalReaderFragment : BasePagerReaderFragment() {
override fun onInitPager(pager: ViewPager2) {
super.onInitPager(pager)
pager.orientation = ViewPager2.ORIENTATION_VERTICAL
}
override fun onCreateAdvancedTransformer(): ViewPager2.PageTransformer = VerticalPageAnimTransformer()
}

View File

@@ -24,7 +24,8 @@ import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import javax.inject.Inject
@AndroidEntryPoint
class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>() {
class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>(),
WebtoonRecyclerView.OnWebtoonScrollListener {
@Inject
lateinit var networkState: NetworkState
@@ -46,7 +47,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
with(binding.recyclerView) {
setHasFixedSize(true)
adapter = readerAdapter
addOnPageScrollListener(PageScrollListener())
addOnPageScrollListener(this@WebtoonReaderFragment)
recyclerLifecycleDispatcher = RecyclerViewLifecycleDispatcher().also {
addOnScrollListener(it)
}
@@ -70,6 +71,15 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
exceptionResolver = exceptionResolver,
)
override fun onScrollChanged(
recyclerView: WebtoonRecyclerView,
dy: Int,
firstVisiblePosition: Int,
lastVisiblePosition: Int,
) {
viewModel.onCurrentPageChanged(firstVisiblePosition, lastVisiblePosition)
}
override suspend fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) = coroutineScope {
val setItems = launch {
requireAdapter().setItems(pages)
@@ -91,7 +101,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
?.restoreScroll(pendingState.scroll)
}
}
notifyPageChanged(position)
viewModel.onCurrentPageChanged(position, position)
} else {
Snackbar.make(requireView(), R.string.not_found_404, Snackbar.LENGTH_SHORT)
.show()
@@ -121,10 +131,6 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
viewBinding?.frame?.onZoomOut()
}
private fun notifyPageChanged(page: Int) {
viewModel.onCurrentPageChanged(page)
}
override fun switchPageBy(delta: Int) {
with(requireViewBinding().recyclerView) {
if (isAnimationEnabled()) {
@@ -147,12 +153,4 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
}
return true
}
private inner class PageScrollListener : WebtoonRecyclerView.OnPageScrollListener() {
override fun onPageChanged(recyclerView: WebtoonRecyclerView, index: Int) {
super.onPageChanged(recyclerView, index)
notifyPageChanged(index)
}
}
}

View File

@@ -6,8 +6,8 @@ import android.view.View
import androidx.core.view.ViewCompat.TYPE_TOUCH
import androidx.core.view.forEach
import androidx.core.view.iterator
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition
import java.util.LinkedList
import java.util.WeakHashMap
@@ -15,7 +15,8 @@ class WebtoonRecyclerView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
private var onPageScrollListeners: MutableList<OnPageScrollListener>? = null
private var onPageScrollListeners = LinkedList<OnWebtoonScrollListener>()
private val scrollDispatcher = WebtoonScrollDispatcher()
private val detachedViews = WeakHashMap<View, Unit>()
private var isFixingScroll: Boolean = false
@@ -103,22 +104,20 @@ class WebtoonRecyclerView @JvmOverloads constructor(
return 0
}
fun addOnPageScrollListener(listener: OnPageScrollListener) {
val list = onPageScrollListeners ?: LinkedList<OnPageScrollListener>().also { onPageScrollListeners = it }
list.add(listener)
fun addOnPageScrollListener(listener: OnWebtoonScrollListener) {
onPageScrollListeners.add(listener)
}
fun removeOnPageScrollListener(listener: OnPageScrollListener) {
onPageScrollListeners?.remove(listener)
fun removeOnPageScrollListener(listener: OnWebtoonScrollListener) {
onPageScrollListeners.remove(listener)
}
private fun notifyScrollChanged(dy: Int) {
val listeners = onPageScrollListeners
if (listeners.isNullOrEmpty()) {
if (listeners.isEmpty()) {
return
}
val centerPosition = findCenterViewPosition()
listeners.forEach { it.dispatchScroll(this, dy, centerPosition) }
scrollDispatcher.dispatchScroll(this, dy)
}
fun relayoutChildren() {
@@ -162,20 +161,30 @@ class WebtoonRecyclerView @JvmOverloads constructor(
else -> false
}
abstract class OnPageScrollListener {
private class WebtoonScrollDispatcher {
private var lastPosition = NO_POSITION
private var firstPos = NO_POSITION
private var lastPos = NO_POSITION
fun dispatchScroll(recyclerView: WebtoonRecyclerView, dy: Int, centerPosition: Int) {
onScroll(recyclerView, dy)
if (centerPosition != NO_POSITION && centerPosition != lastPosition) {
lastPosition = centerPosition
onPageChanged(recyclerView, centerPosition)
fun dispatchScroll(rv: WebtoonRecyclerView, dy: Int) {
val lm = rv.layoutManager as? LinearLayoutManager
if (lm == null) {
firstPos = NO_POSITION
lastPos = NO_POSITION
return
}
val newFirstPos = lm.findFirstVisibleItemPosition()
val newLastPos = lm.findLastVisibleItemPosition()
if (newFirstPos != firstPos || newLastPos != lastPos) {
firstPos = newFirstPos
lastPos = newLastPos
rv.onPageScrollListeners.forEach { it.onScrollChanged(rv, dy, newFirstPos, newLastPos) }
}
}
}
open fun onScroll(recyclerView: WebtoonRecyclerView, dy: Int) = Unit
interface OnWebtoonScrollListener {
open fun onPageChanged(recyclerView: WebtoonRecyclerView, index: Int) = Unit
fun onScrollChanged(recyclerView: WebtoonRecyclerView, dy: Int, firstVisiblePosition: Int, lastVisiblePosition: Int)
}
}

View File

@@ -8,7 +8,6 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
@@ -24,13 +23,6 @@ class PageThumbnailAdapter(
}
override fun getSectionText(context: Context, position: Int): CharSequence? {
val list = items
for (i in (0..position).reversed()) {
val item = list.getOrNull(i) ?: continue
if (item is ListHeader) {
return item.getText(context)
}
}
return null
return findHeader(position)?.getText(context)
}
}

View File

@@ -111,6 +111,7 @@ class RemoteListFragment : MangaListFragment(), FilterOwner {
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
menu.findItem(R.id.action_search)?.isVisible = viewModel.isSearchAvailable
menu.findItem(R.id.action_random)?.isEnabled = !viewModel.isRandomLoading.value
menu.findItem(R.id.action_filter_reset)?.isVisible = viewModel.header.value.isFilterApplied
}

View File

@@ -68,6 +68,9 @@ open class RemoteListViewModel @Inject constructor(
private var loadingJob: Job? = null
private var randomJob: Job? = null
val isSearchAvailable: Boolean
get() = repository.isSearchSupported
override val content = combine(
mangaList.map { it?.distinctById()?.skipNsfwIfNeeded() },
listMode,

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.scrobbling.common.domain
import androidx.annotation.FloatRange
import androidx.collection.LongSparseArray
import androidx.collection.getOrElse
import androidx.core.text.parseAsHtml
@@ -11,9 +12,11 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.util.ext.findKeyByValue
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerMangaInfo
@@ -21,13 +24,12 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.util.EnumMap
abstract class Scrobbler(
protected val db: MangaDatabase,
val scrobblerService: ScrobblerService,
private val repository: org.koitharu.kotatsu.scrobbling.common.data.ScrobblerRepository,
private val repository: ScrobblerRepository,
) {
private val infoCache = LongSparseArray<ScrobblerMangaInfo>()
@@ -76,7 +78,12 @@ abstract class Scrobbler(
return entity.toScrobblingInfo()
}
abstract suspend fun updateScrobblingInfo(mangaId: Long, rating: Float, status: ScrobblingStatus?, comment: String?)
abstract suspend fun updateScrobblingInfo(
mangaId: Long,
@FloatRange(from = 0.0, to = 1.0) rating: Float,
status: ScrobblingStatus?,
comment: String?,
)
fun observeScrobblingInfo(mangaId: Long): Flow<ScrobblingInfo?> {
return db.getScrobblingDao().observe(scrobblerService.id, mangaId)

View File

@@ -23,6 +23,8 @@ import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setProgressIcon
import org.koitharu.kotatsu.core.util.ext.setTabsEnabled
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetScrobblingSelectorBinding
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
@@ -80,6 +82,15 @@ class ScrobblingSelectorSheet :
viewModel.onClose.observeEvent(viewLifecycleOwner) {
dismiss()
}
viewModel.isLoading.observe(viewLifecycleOwner) { isLoading ->
binding.buttonDone.isEnabled = !isLoading
if (isLoading) {
binding.buttonDone.setProgressIcon()
} else {
binding.buttonDone.icon = null
}
binding.tabs.setTabsEnabled(!isLoading)
}
viewModel.selectedScrobblerIndex.observe(viewLifecycleOwner) { index ->
val tab = binding.tabs.getTabAt(index)
if (tab != null && !tab.isSelected) {
@@ -100,7 +111,7 @@ class ScrobblingSelectorSheet :
}
override fun onItemClick(item: ScrobblerManga, view: View) {
viewModel.selectedItemId.value = item.id
viewModel.selectItem(item.id)
}
override fun onRetryClick(error: Throwable) {

View File

@@ -14,20 +14,24 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.requireValue
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
import org.koitharu.kotatsu.scrobbling.common.ui.selector.model.ScrobblerHint
import javax.inject.Inject
@@ -35,6 +39,8 @@ import javax.inject.Inject
class ScrobblingSelectorViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
scrobblers: Set<@JvmSuppressWildcards Scrobbler>,
private val historyRepository: HistoryRepository,
private val mangaRepositoryFactory: MangaRepository.Factory,
) : BaseViewModel() {
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
@@ -92,6 +98,13 @@ class ScrobblingSelectorViewModel @Inject constructor(
loadList(append = false)
}
fun selectItem(id: Long) {
if (doneJob?.isActive == true) {
return
}
selectedItemId.value = id
}
fun loadNextPage() {
if (scrobblerMangaList.value.isNotEmpty() && hasNextPage.value) {
loadList(append = true)
@@ -109,7 +122,7 @@ class ScrobblingSelectorViewModel @Inject constructor(
if (loadingJob?.isActive == true) {
return
}
loadingJob = launchLoadingJob(Dispatchers.Default) {
loadingJob = launchJob(Dispatchers.Default) {
listError.value = null
val offset = if (append) scrobblerMangaList.value.size else 0
runCatchingCancellable {
@@ -136,8 +149,31 @@ class ScrobblingSelectorViewModel @Inject constructor(
if (targetId == NO_ID) {
onClose.call(Unit)
}
doneJob = launchJob(Dispatchers.Default) {
doneJob = launchLoadingJob(Dispatchers.Default) {
val prevInfo = currentScrobbler.getScrobblingInfoOrNull(manga.id)
currentScrobbler.linkManga(manga.id, targetId)
val history = historyRepository.getOne(manga)
currentScrobbler.updateScrobblingInfo(
mangaId = manga.id,
rating = prevInfo?.rating ?: manga.rating,
status = prevInfo?.status ?: if (history == null) {
ScrobblingStatus.PLANNED
} else {
ScrobblingStatus.READING
},
comment = prevInfo?.comment,
)
if (history != null) {
val chapter = mangaRepositoryFactory.create(manga.source)
.getDetails(manga)
.chapters?.findById(history.chapterId)
if (chapter != null) {
currentScrobbler.scrobble(
mangaId = manga.id,
chapter = chapter,
)
}
}
onClose.call(Unit)
}
}

View File

@@ -118,7 +118,7 @@ class MangaListActivity :
fm.commit {
setReorderingAllowed(true)
val fragment = if (source == MangaSource.LOCAL) {
LocalListFragment.newInstance()
LocalListFragment()
} else {
RemoteListFragment.newInstance(source)
}

View File

@@ -113,10 +113,14 @@ class MultiSearchViewModel @Inject constructor(
}
val semaphore = Semaphore(MAX_PARALLELISM)
for (source in sources) {
val repository = mangaRepositoryFactory.create(source)
if (!repository.isSearchSupported) {
continue
}
launch {
val item = runCatchingCancellable {
semaphore.withPermit {
mangaRepositoryFactory.create(source).getList(offset = 0, filter = MangaListFilter.Search(q))
repository.getList(offset = 0, filter = MangaListFilter.Search(q))
.toUi(ListMode.GRID, extraProvider)
}
}.fold(

View File

@@ -8,6 +8,7 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.core.view.MenuProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
import org.koitharu.kotatsu.core.util.ext.resolve
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import com.google.android.material.R as materialR
@@ -43,7 +44,7 @@ class SearchSuggestionMenuProvider(
}
private fun clearSearchHistory() {
MaterialAlertDialogBuilder(context, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered)
MaterialAlertDialogBuilder(context, DIALOG_THEME_CENTERED)
.setTitle(R.string.clear_search_history)
.setIcon(R.drawable.ic_clear_all)
.setMessage(R.string.text_clear_search_history_prompt)

View File

@@ -16,7 +16,8 @@ import io.noties.markwon.Markwon
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.core.util.FileSize
import com.google.android.material.R as materialR
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
class AppUpdateDialog(private val activity: AppCompatActivity) {
@@ -42,10 +43,7 @@ class AppUpdateDialog(private val activity: AppCompatActivity) {
appendLine()
append(Markwon.create(activity).toMarkdown(version.description))
}
MaterialAlertDialogBuilder(
activity,
materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered,
)
MaterialAlertDialogBuilder(activity, DIALOG_THEME_CENTERED)
.setTitle(R.string.app_update_available)
.setMessage(message)
.setIcon(R.drawable.ic_app_update)
@@ -68,7 +66,7 @@ class AppUpdateDialog(private val activity: AppCompatActivity) {
}
}
private fun downloadUpdateImpl() {
private fun downloadUpdateImpl() = runCatching {
val version = latestVersion
val url = version.apkUrl.toUri()
val dm = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
@@ -78,7 +76,10 @@ class AppUpdateDialog(private val activity: AppCompatActivity) {
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setMimeType("application/vnd.android.package-archive")
dm.enqueue(request)
}.onSuccess {
Toast.makeText(activity, R.string.download_started, Toast.LENGTH_SHORT).show()
}.onFailure { e ->
Toast.makeText(activity, e.getDisplayMessage(activity.resources), Toast.LENGTH_SHORT).show()
}
private fun openInBrowser() {

View File

@@ -352,7 +352,7 @@ class SuggestionsWorker @AssistedInject constructor(
val request = PeriodicWorkRequestBuilder<SuggestionsWorker>(6, TimeUnit.HOURS)
.setConstraints(createConstraints())
.addTag(TAG)
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES)
.setBackoffCriteria(BackoffPolicy.LINEAR, 1, TimeUnit.HOURS)
.build()
workManager
.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request)

View File

@@ -13,7 +13,6 @@ import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver
@@ -45,13 +44,6 @@ class FeedAdapter(
}
override fun getSectionText(context: Context, position: Int): CharSequence? {
val list = items
for (i in (0..position).reversed()) {
val item = list.getOrNull(i) ?: continue
if (item is ListHeader) {
return item.getText(context)
}
}
return null
return findHeader(position)?.getText(context)
}
}

View File

@@ -323,7 +323,7 @@ class TrackWorker @AssistedInject constructor(
val request = PeriodicWorkRequestBuilder<TrackWorker>(4, TimeUnit.HOURS)
.setConstraints(constraints)
.addTag(TAG)
.setBackoffCriteria(BackoffPolicy.LINEAR, 5, TimeUnit.MINUTES)
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.MINUTES)
.build()
workManager
.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request)
@@ -373,7 +373,7 @@ class TrackWorker @AssistedInject constructor(
const val TAG = "tracking"
const val TAG_ONESHOT = "tracking_oneshot"
const val MAX_PARALLELISM = 3
const val MAX_ATTEMPTS = 4
const val MAX_ATTEMPTS = 3
const val DATA_KEY_SUCCESS = "success"
const val DATA_KEY_FAILED = "failed"
const val KEY_RETRY_IDS = "retry"

View File

@@ -4,7 +4,7 @@
<item>
<shape
android:shape="rectangle">
<solid android:color="?attr/colorOutline"/>
<solid android:color="?attr/colorSurfaceVariant"/>
</shape>
</item>
<!-- ..., then draws a rectangle with the container color to cover the area not for the divider. -->

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="?android:attr/colorControlHighlight">
<item
android:id="@android:id/mask"
android:left="8dp"
android:right="8dp">
<shape android:shape="rectangle">
<corners android:radius="@dimen/list_selector_corner" />
<solid android:color="@color/selector_overlay" />
</shape>
</item>
</ripple>

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