Compare commits

..

59 Commits

Author SHA1 Message Date
Koitharu
5df55d1fe9 Draft double reader implementation 2023-10-10 09:16:16 +03:00
Koitharu
fbb267e11c Update parsers 2023-10-09 10:10:55 +03:00
Koitharu
5740af05fa Update dependencies 2023-10-06 09:44:53 +03:00
Koitharu
ae2cc1dffc Add support for Dropped manga state 2023-10-04 15:48:28 +03:00
Koitharu
a5b9712e9f Update typography 2023-10-04 15:43:32 +03:00
Koitharu
c013e6e4f4 Adjust keyboard incognito mode 2023-10-04 15:35:14 +03:00
Koitharu
0249faa3f6 Remove bold from feed 2023-10-04 15:29:10 +03:00
Koitharu
9c52423dc0 Fix statusbar color 2023-10-04 15:26:14 +03:00
Eduardo Malaspina
1f7e5458ae Translated using Weblate (Spanish)
Currently translated at 100.0% (489 of 489 strings)

Co-authored-by: Eduardo Malaspina <vaio0@swismail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-10-04 15:12:03 +03:00
Макар Разин
b4d487b398 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (489 of 489 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (489 of 489 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (489 of 489 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-10-04 15:12:03 +03:00
Koitharu
0281f1eadb Incognito mode indicator 2023-10-04 15:00:46 +03:00
Koitharu
1bd9b655f9 Update parsers 2023-10-04 14:07:14 +03:00
Koitharu
ed87292921 Adaptive tags suggestion 2023-10-04 12:25:09 +03:00
Koitharu
861be7614e Fix back navigation 2023-10-04 11:44:49 +03:00
Koitharu
717fe8748a Fix suggestion notification text 2023-10-04 11:37:39 +03:00
Koitharu
c7a1312cd6 Fix check updates for saved manga #506 2023-10-02 16:49:20 +03:00
Koitharu
b2927854d4 Make keep screen on in reader optional 2023-10-02 16:39:14 +03:00
Koitharu
cfda150630 Fix crash on request pin shortcut 2023-10-02 15:22:35 +03:00
Koitharu
4fa1382ce9 Fix crash on download update 2023-10-02 15:15:44 +03:00
Koitharu
43075c52d1 Improve automatic mirror switching 2023-10-02 14:49:45 +03:00
Koitharu
87942747fc Update parsers 2023-10-02 13:34:40 +03:00
Koitharu
bb6cd73acd Update parsers 2023-09-30 17:44:13 +03:00
kuragehime
6790e5b0d4 Translated using Weblate (Japanese)
Currently translated at 100.0% (487 of 487 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-09-30 17:16:27 +03:00
Макар Разин
845c356a73 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (487 of 487 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (487 of 487 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/uk/
Translation: Kotatsu/Strings
2023-09-30 17:16:27 +03:00
return_null
34499ea77d Translated using Weblate (Chinese (Simplified))
Currently translated at 99.3% (484 of 487 strings)

Co-authored-by: return_null <demolang@dismail.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-09-30 17:16:27 +03:00
InfinityDouki56
6210864280 Translated using Weblate (Filipino)
Currently translated at 89.3% (435 of 487 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-09-30 17:16:27 +03:00
Crono
19084419c7 Translated using Weblate (Portuguese)
Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Portuguese)

Currently translated at 100.0% (487 of 487 strings)

Added translation using Weblate (Portuguese)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (487 of 487 strings)

Translated using Weblate (Portuguese)

Currently translated at 90.1% (439 of 487 strings)

Co-authored-by: Crono <cronoreader@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/pt_BR/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt_BR/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-09-30 17:16:27 +03:00
J. Lavoie
84ce4c508c Translated using Weblate (French)
Currently translated at 100.0% (487 of 487 strings)

Co-authored-by: J. Lavoie <j.lavoie@net-c.ca>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fr/
Translation: Kotatsu/Strings
2023-09-20 12:34:26 +03:00
gallegonovato
0db8fafe61 Translated using Weblate (Spanish)
Currently translated at 100.0% (487 of 487 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-09-20 12:34:26 +03:00
Koitharu
fed241215e Update parsers 2023-09-20 12:34:12 +03:00
Koitharu
761f24daf9 Fix crashes 2023-09-20 09:40:20 +03:00
Koitharu
a435435496 Fix webtoon zoom controls visibility 2023-09-18 13:57:07 +03:00
Koitharu
81e8c25563 Reorder reader settings items 2023-09-18 13:51:02 +03:00
Koitharu
e3504c3b1e Translated using Weblate (Russian)
Currently translated at 100.0% (487 of 487 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-09-18 13:48:30 +03:00
Макар Разин
2601c12348 Translated using Weblate (Russian)
Currently translated at 100.0% (483 of 483 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (483 of 483 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
2023-09-18 13:48:30 +03:00
Koitharu
138cf44e37 Fix crash with ActivityNotFoundException 2023-09-18 13:42:28 +03:00
Koitharu
65d83e0921 Fix search action #495 2023-09-18 13:34:08 +03:00
Koitharu
6e1cd05fa8 Zoom control buttons in reader 2023-09-18 13:25:53 +03:00
Koitharu
8398c01929 Improve keyboard control in reader 2023-09-18 12:49:37 +03:00
Koitharu
835c49ae79 Download updates directly 2023-09-15 13:34:13 +03:00
Koitharu
36065ccf6c Pin source shortcuts 2023-09-15 12:12:06 +03:00
Koitharu
4ab40566f7 Fix sync server address configuration 2023-09-15 11:14:06 +03:00
return_null
bf01a4d1ab Translated using Weblate (Chinese (Simplified))
Currently translated at 99.3% (480 of 483 strings)

Co-authored-by: return_null <demolang@dismail.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
2023-09-14 13:41:58 +03:00
Koitharu
8dce9dcc3f Fix pages thumbnails loading 2023-09-14 13:38:02 +03:00
Koitharu
d872044252 Improve mouse interaction 2023-09-14 13:31:18 +03:00
Koitharu
f4313525c2 Update dependencies 2023-09-14 09:05:50 +03:00
Koitharu
4eb4ec7de0 Fix nsfw sources filtering 2023-09-12 19:33:49 +03:00
Koitharu
ecb4dd87d9 Update parsers 2023-09-12 19:28:45 +03:00
Nayuki
3d0f5f75cd Translated using Weblate (Thai)
Currently translated at 63.3% (306 of 483 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
Bander AL-shreef
c5462e8454 Translated using Weblate (Arabic)
Currently translated at 37.4% (181 of 483 strings)

Co-authored-by: Bander AL-shreef <bander.alshreef@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
return_null
5039e324fb Translated using Weblate (Chinese (Simplified))
Currently translated at 99.3% (480 of 483 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 93.1% (450 of 483 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (7 of 7 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 92.5% (447 of 483 strings)

Co-authored-by: return_null <demolang@dismail.de>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/zh_Hans/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/zh_Hans/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-09-12 18:31:26 +03:00
Макар Разин
b251b3e654 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (483 of 483 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (483 of 483 strings)

Translated using Weblate (Belarusian)

Currently translated at 100.0% (483 of 483 strings)

Co-authored-by: Макар Разин <makarrazin14@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/be/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/uk/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
gallegonovato
5f10070564 Translated using Weblate (Spanish)
Currently translated at 100.0% (483 of 483 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
Clxff H3r4ld0
3da6f80eb6 Translated using Weblate (Indonesian)
Currently translated at 100.0% (481 of 481 strings)

Co-authored-by: Clxff H3r4ld0 <123844876+clxf12@users.noreply.github.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/id/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
InfinityDouki56
4b2cfdb972 Translated using Weblate (Filipino)
Currently translated at 89.7% (428 of 477 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
kuragehime
51387ace7e Translated using Weblate (Japanese)
Currently translated at 100.0% (483 of 483 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (481 of 481 strings)

Translated using Weblate (Japanese)

Currently translated at 100.0% (477 of 477 strings)

Co-authored-by: kuragehime <kuragehime641@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ja/
Translation: Kotatsu/Strings
2023-09-12 18:31:26 +03:00
Koitharu
2bdb83ff28 Fix navigation reordering 2023-09-12 13:38:03 +03:00
Koitharu
a1b85433ec Fix bookmarks crash #492 2023-09-12 13:38:03 +03:00
Isira Seneviratne
ca5207c658 Use ancestors and descendants extensions 2023-09-09 17:52:12 +03:00
129 changed files with 2387 additions and 555 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 577 versionCode = 584
versionName = '6.1' versionName = '6.1.6'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner" testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
ksp { ksp {
@@ -81,7 +81,7 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:aae3fa3b05') { implementation('com.github.KotatsuApp:kotatsu-parsers:400a90464e') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
@@ -89,21 +89,21 @@ dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.10.1' implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.activity:activity-ktx:1.7.2' implementation 'androidx.activity:activity-ktx:1.8.0'
implementation 'androidx.fragment:fragment-ktx:1.6.1' implementation 'androidx.fragment:fragment-ktx:1.6.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2'
implementation 'androidx.lifecycle:lifecycle-service:2.6.1' implementation 'androidx.lifecycle:lifecycle-service:2.6.2'
implementation 'androidx.lifecycle:lifecycle-process:2.6.1' implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.3.1' implementation 'androidx.recyclerview:recyclerview:1.3.1'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.preference:preference-ktx:1.2.1'
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
implementation 'com.google.android.material:material:1.9.0' implementation 'com.google.android.material:material:1.10.0'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.1' implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.2'
// TODO https://issuetracker.google.com/issues/254846063 // TODO https://issuetracker.google.com/issues/254846063
implementation 'androidx.work:work-runtime-ktx:2.8.1' implementation 'androidx.work:work-runtime-ktx:2.8.1'
@@ -120,24 +120,24 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:4.11.0' implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.11.0'
implementation 'com.squareup.okio:okio:3.5.0' implementation 'com.squareup.okio:okio:3.6.0'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
implementation 'com.google.dagger:hilt-android:2.47' implementation 'com.google.dagger:hilt-android:2.48.1'
kapt 'com.google.dagger:hilt-compiler:2.47' kapt 'com.google.dagger:hilt-compiler:2.48.1'
implementation 'androidx.hilt:hilt-work:1.0.0' implementation 'androidx.hilt:hilt-work:1.0.0'
kapt 'androidx.hilt:hilt-compiler:1.0.0' kapt 'androidx.hilt:hilt-compiler:1.0.0'
implementation 'io.coil-kt:coil-base:2.4.0' implementation 'io.coil-kt:coil-base:2.4.0'
implementation 'io.coil-kt:coil-svg:2.4.0' implementation 'io.coil-kt:coil-svg:2.4.0'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:9b1d20be67' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:169806d928'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'
implementation 'io.noties.markwon:core:4.6.2' implementation 'io.noties.markwon:core:4.6.2'
implementation 'ch.acra:acra-http:5.11.1' implementation 'ch.acra:acra-http:5.11.2'
implementation 'ch.acra:acra-dialog:5.11.1' implementation 'ch.acra:acra-dialog:5.11.2'
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
@@ -155,6 +155,6 @@ dependencies {
androidTestImplementation 'androidx.room:room-testing:2.5.2' androidTestImplementation 'androidx.room:room-testing:2.5.2'
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.47' androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48.1'
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.47' kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.48.1'
} }

View File

@@ -19,6 +19,7 @@
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" /> <uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" /> android:maxSdkVersion="29" />
@@ -95,7 +96,12 @@
android:label="@string/search" /> android:label="@string/search" />
<activity <activity
android:name="org.koitharu.kotatsu.search.ui.MangaListActivity" android:name="org.koitharu.kotatsu.search.ui.MangaListActivity"
android:label="@string/search_manga" /> android:exported="true"
android:label="@string/manga_list">
<intent-filter>
<action android:name="${applicationId}.action.EXPLORE_MANGA" />
</intent-filter>
</activity>
<activity <activity
android:name="org.koitharu.kotatsu.history.ui.HistoryActivity" android:name="org.koitharu.kotatsu.history.ui.HistoryActivity"
android:label="@string/history" /> android:label="@string/history" />
@@ -138,8 +144,8 @@
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name="org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity" android:name="org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:autoRemoveFromRecents="true" android:autoRemoveFromRecents="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<activity <activity
android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity" android:name="org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity"
@@ -314,6 +320,13 @@
android:name="android.appwidget.provider" android:name="android.appwidget.provider"
android:resource="@xml/widget_recent" /> android:resource="@xml/widget_recent" />
</receiver> </receiver>
<receiver
android:name="org.koitharu.kotatsu.settings.about.UpdateDownloadReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
</intent-filter>
</receiver>
<meta-data <meta-data
android:name="android.webkit.WebView.EnableSafeBrowsing" android:name="android.webkit.WebView.EnableSafeBrowsing"

View File

@@ -14,7 +14,6 @@ import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.Bookmark
@@ -25,8 +24,7 @@ import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.ui.util.reverseAsync
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
@@ -38,7 +36,6 @@ import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import javax.inject.Inject import javax.inject.Inject
@@ -105,7 +102,7 @@ class BookmarksFragment :
viewLifecycleOwner, viewLifecycleOwner,
SnackbarErrorObserver(binding.recyclerView, this) SnackbarErrorObserver(binding.recyclerView, this)
) )
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ::onActionDone) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -184,17 +181,6 @@ class BookmarksFragment :
} }
} }
private fun onActionDone(action: ReversibleAction) {
val handle = action.handle
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
val snackbar =
Snackbar.make((activity as SnackbarOwner).snackbarHost, action.stringResId, length)
if (handle != null) {
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
}
snackbar.show()
}
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable { private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable {
init { init {

View File

@@ -35,6 +35,7 @@ class BookmarksSheetViewModel @Inject constructor(
val content: StateFlow<List<ListModel>> = bookmarksRepository.observeBookmarks(manga) val content: StateFlow<List<ListModel>> = bookmarksRepository.observeBookmarks(manga)
.map { mapList(it) } .map { mapList(it) }
.withErrorHandling()
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingFooter())) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingFooter()))
private suspend fun mapList(bookmarks: List<Bookmark>): List<ListModel> { private suspend fun mapList(bookmarks: List<Bookmark>): List<ListModel> {

View File

@@ -51,6 +51,28 @@ abstract class TagsDao {
) )
abstract suspend fun findTags(query: String, limit: Int): List<TagEntity> abstract suspend fun findTags(query: String, limit: Int): List<TagEntity>
@Query(
"""
SELECT tags.* FROM manga_tags
LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id
WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id = :tagId)
GROUP BY tags.tag_id
ORDER BY COUNT(manga_id) DESC;
""",
)
abstract suspend fun findRelatedTags(tagId: Long): List<TagEntity>
@Query(
"""
SELECT tags.* FROM manga_tags
LEFT JOIN tags ON tags.tag_id = manga_tags.tag_id
WHERE manga_tags.manga_id IN (SELECT manga_id FROM manga_tags WHERE tag_id IN (:ids))
GROUP BY tags.tag_id
ORDER BY COUNT(manga_id) DESC;
""",
)
abstract suspend fun findRelatedTags(ids: Set<Long>): List<TagEntity>
@Upsert @Upsert
abstract suspend fun upsert(tags: Iterable<TagEntity>) abstract suspend fun upsert(tags: Iterable<TagEntity>)
} }

View File

@@ -19,6 +19,8 @@ fun TagEntity.toMangaTag() = MangaTag(
fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag) fun Collection<TagEntity>.toMangaTags() = mapToSet(TagEntity::toMangaTag)
fun Collection<TagEntity>.toMangaTagsList() = map(TagEntity::toMangaTag)
fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga( fun MangaEntity.toManga(tags: Set<MangaTag>) = Manga(
id = this.id, id = this.id,
title = this.title, title = this.title,

View File

@@ -1,6 +1,9 @@
package org.koitharu.kotatsu.core.network package org.koitharu.kotatsu.core.network
import androidx.collection.ArraySet
import dagger.Lazy import dagger.Lazy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
@@ -13,6 +16,7 @@ import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.EnumMap
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -22,9 +26,15 @@ class MirrorSwitchInterceptor @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
) : Interceptor { ) : Interceptor {
private val locks = EnumMap<MangaSource, Any>(MangaSource::class.java)
private val blacklist = EnumMap<MangaSource, MutableSet<String>>(MangaSource::class.java)
val isEnabled: Boolean
get() = settings.isMirrorSwitchingAvailable
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request() val request = chain.request()
if (!settings.isMirrorSwitchingAvailable) { if (!isEnabled) {
return chain.proceed(request) return chain.proceed(request)
} }
return try { return try {
@@ -43,6 +53,30 @@ class MirrorSwitchInterceptor @Inject constructor(
} }
} }
suspend fun trySwitchMirror(repository: RemoteMangaRepository): Boolean = runInterruptible(Dispatchers.Default) {
if (!isEnabled) {
return@runInterruptible false
}
val mirrors = repository.getAvailableMirrors()
if (mirrors.size <= 1) {
return@runInterruptible false
}
synchronized(obtainLock(repository.source)) {
val currentMirror = repository.domain
addToBlacklist(repository.source, currentMirror)
val newMirror = mirrors.firstOrNull { x ->
x != currentMirror && !isBlacklisted(repository.source, x)
} ?: return@synchronized false
repository.domain = newMirror
true
}
}
fun rollback(repository: RemoteMangaRepository, oldMirror: String) = synchronized(obtainLock(repository.source)) {
blacklist[repository.source]?.remove(oldMirror)
repository.domain = oldMirror
}
private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? { private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? {
val source = request.tag(MangaSource::class.java) ?: return null val source = request.tag(MangaSource::class.java) ?: return null
val repository = mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository ?: return null val repository = mangaRepositoryFactoryLazy.get().create(source) as? RemoteMangaRepository ?: return null
@@ -50,7 +84,9 @@ class MirrorSwitchInterceptor @Inject constructor(
if (mirrors.isEmpty()) { if (mirrors.isEmpty()) {
return null return null
} }
return tryMirrors(repository, mirrors, chain, request) return synchronized(obtainLock(repository.source)) {
tryMirrors(repository, mirrors, chain, request)
}
} }
private fun tryMirrors( private fun tryMirrors(
@@ -66,7 +102,7 @@ class MirrorSwitchInterceptor @Inject constructor(
} }
val urlBuilder = url.newBuilder() val urlBuilder = url.newBuilder()
for (mirror in mirrors) { for (mirror in mirrors) {
if (mirror == currentDomain) { if (mirror == currentDomain || isBlacklisted(repository.source, mirror)) {
continue continue
} }
val newHost = hostOf(url.host, mirror) ?: continue val newHost = hostOf(url.host, mirror) ?: continue
@@ -75,6 +111,7 @@ class MirrorSwitchInterceptor @Inject constructor(
.build() .build()
val response = chain.proceed(newRequest) val response = chain.proceed(newRequest)
if (response.isFailed) { if (response.isFailed) {
addToBlacklist(repository.source, mirror)
response.closeQuietly() response.closeQuietly()
} else { } else {
repository.domain = mirror repository.domain = mirror
@@ -104,4 +141,18 @@ class MirrorSwitchInterceptor @Inject constructor(
private fun ResponseBody.copy(): ResponseBody { private fun ResponseBody.copy(): ResponseBody {
return source().readByteArray().toResponseBody(contentType()) return source().readByteArray().toResponseBody(contentType())
} }
private fun obtainLock(source: MangaSource): Any = locks.getOrPut(source) {
Any()
}
private fun isBlacklisted(source: MangaSource, domain: String): Boolean {
return blacklist[source]?.contains(domain) == true
}
private fun addToBlacklist(source: MangaSource, domain: String) {
blacklist.getOrPut(source) {
ArraySet(2)
}.add(domain)
}
} }

View File

@@ -21,6 +21,7 @@ import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.db.TABLE_HISTORY
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation import org.koitharu.kotatsu.core.ui.image.ThumbnailTransformation
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
@@ -29,8 +30,10 @@ import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.search.ui.MangaListActivity
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -73,8 +76,18 @@ class AppShortcutManager @Inject constructor(
} }
} }
suspend fun requestPinShortcut(manga: Manga): Boolean { suspend fun requestPinShortcut(manga: Manga): Boolean = try {
return ShortcutManagerCompat.requestPinShortcut(context, buildShortcutInfo(manga), null) ShortcutManagerCompat.requestPinShortcut(context, buildShortcutInfo(manga), null)
} catch (e: IllegalStateException) {
e.printStackTraceDebug()
false
}
suspend fun requestPinShortcut(source: MangaSource): Boolean = try {
ShortcutManagerCompat.requestPinShortcut(context, buildShortcutInfo(source), null)
} catch (e: IllegalStateException) {
e.printStackTraceDebug()
false
} }
@VisibleForTesting @VisibleForTesting
@@ -86,6 +99,11 @@ class AppShortcutManager @Inject constructor(
ShortcutManagerCompat.reportShortcutUsed(context, mangaId.toString()) ShortcutManagerCompat.reportShortcutUsed(context, mangaId.toString())
} }
fun isDynamicShortcutsAvailable(): Boolean {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 &&
context.getSystemService(ShortcutManager::class.java).maxShortcutCountPerActivity > 0
}
private suspend fun updateShortcutsImpl() = runCatchingCancellable { private suspend fun updateShortcutsImpl() = runCatchingCancellable {
val maxShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context).coerceAtLeast(5) val maxShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context).coerceAtLeast(5)
val shortcuts = historyRepository.getList(0, maxShortcuts) val shortcuts = historyRepository.getList(0, maxShortcuts)
@@ -132,8 +150,25 @@ class AppShortcutManager @Inject constructor(
.build() .build()
} }
fun isDynamicShortcutsAvailable(): Boolean { private suspend fun buildShortcutInfo(source: MangaSource): ShortcutInfoCompat {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && val icon = runCatchingCancellable {
context.getSystemService(ShortcutManager::class.java).maxShortcutCountPerActivity > 0 coil.execute(
ImageRequest.Builder(context)
.data(source.faviconUri())
.size(iconSize)
.scale(Scale.FIT)
.build(),
).getDrawableOrThrow().toBitmap()
}.fold(
onSuccess = { IconCompat.createWithAdaptiveBitmap(it) },
onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) },
)
return ShortcutInfoCompat.Builder(context, source.name)
.setShortLabel(source.title)
.setLongLabel(source.title)
.setIcon(icon)
.setLongLived(true)
.setIntent(MangaListActivity.newIntent(context, source))
.build()
} }
} }

View File

@@ -17,7 +17,7 @@ import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.util.* import java.util.Locale
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume import kotlin.coroutines.resume
@@ -50,7 +50,7 @@ class MangaLoaderContextImpl @Inject constructor(
} }
override fun encodeBase64(data: ByteArray): String { override fun encodeBase64(data: ByteArray): String {
return Base64.encodeToString(data, Base64.NO_PADDING) return Base64.encodeToString(data, Base64.NO_WRAP)
} }
override fun decodeBase64(data: String): ByteArray { override fun decodeBase64(data: String): ByteArray {

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.parser
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.MangaLoaderContext
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -43,6 +44,7 @@ interface MangaRepository {
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val loaderContext: MangaLoaderContext, private val loaderContext: MangaLoaderContext,
private val contentCache: ContentCache, private val contentCache: ContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
) { ) {
private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java) private val cache = EnumMap<MangaSource, WeakReference<RemoteMangaRepository>>(MangaSource::class.java)
@@ -55,7 +57,11 @@ interface MangaRepository {
cache[source]?.get()?.let { return it } cache[source]?.get()?.let { return it }
return synchronized(cache) { return synchronized(cache) {
cache[source]?.get()?.let { return it } cache[source]?.get()?.let { return it }
val repository = RemoteMangaRepository(MangaParser(source, loaderContext), contentCache) val repository = RemoteMangaRepository(
parser = MangaParser(source, loaderContext),
cache = contentCache,
mirrorSwitchInterceptor = mirrorSwitchInterceptor,
)
cache[source] = WeakReference(repository) cache[source] = WeakReference(repository)
repository repository
} }

View File

@@ -13,11 +13,13 @@ import okhttp3.Response
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.cache.ContentCache import org.koitharu.kotatsu.core.cache.ContentCache
import org.koitharu.kotatsu.core.cache.SafeDeferred import org.koitharu.kotatsu.core.cache.SafeDeferred
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.ParseException
import org.koitharu.kotatsu.parsers.model.Favicons import org.koitharu.kotatsu.parsers.model.Favicons
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
@@ -31,6 +33,7 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
class RemoteMangaRepository( class RemoteMangaRepository(
private val parser: MangaParser, private val parser: MangaParser,
private val cache: ContentCache, private val cache: ContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor,
) : MangaRepository, Interceptor { ) : MangaRepository, Interceptor {
override val source: MangaSource override val source: MangaSource
@@ -66,11 +69,15 @@ class RemoteMangaRepository(
} }
override suspend fun getList(offset: Int, query: String): List<Manga> { override suspend fun getList(offset: Int, query: String): List<Manga> {
return parser.getList(offset, query) return mirrorSwitchInterceptor.withMirrorSwitching {
parser.getList(offset, query)
}
} }
override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> { override suspend fun getList(offset: Int, tags: Set<MangaTag>?, sortOrder: SortOrder?): List<Manga> {
return parser.getList(offset, tags, sortOrder) return mirrorSwitchInterceptor.withMirrorSwitching {
parser.getList(offset, tags, sortOrder)
}
} }
override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, withCache = true) override suspend fun getDetails(manga: Manga): Manga = getDetails(manga, withCache = true)
@@ -78,17 +85,25 @@ class RemoteMangaRepository(
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> { override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
cache.getPages(source, chapter.url)?.let { return it } cache.getPages(source, chapter.url)?.let { return it }
val pages = asyncSafe { val pages = asyncSafe {
parser.getPages(chapter).distinctById() mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPages(chapter).distinctById()
}
} }
cache.putPages(source, chapter.url, pages) cache.putPages(source, chapter.url, pages)
return pages.await() return pages.await()
} }
override suspend fun getPageUrl(page: MangaPage): String = parser.getPageUrl(page) override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getPageUrl(page)
}
override suspend fun getTags(): Set<MangaTag> = parser.getTags() override suspend fun getTags(): Set<MangaTag> = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getTags()
}
suspend fun getFavicons(): Favicons = parser.getFavicons() suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
parser.getFavicons()
}
override suspend fun getRelated(seed: Manga): List<Manga> { override suspend fun getRelated(seed: Manga): List<Manga> {
cache.getRelatedManga(source, seed.url)?.let { return it } cache.getRelatedManga(source, seed.url)?.let { return it }
@@ -105,7 +120,9 @@ class RemoteMangaRepository(
} }
cache.getDetails(source, manga.url)?.let { return it } cache.getDetails(source, manga.url)?.let { return it }
val details = asyncSafe { val details = asyncSafe {
parser.getDetails(manga) mirrorSwitchInterceptor.withMirrorSwitching {
parser.getDetails(manga)
}
} }
cache.putDetails(source, manga.url, details) cache.putDetails(source, manga.url, details)
return details.await() return details.await()
@@ -155,4 +172,33 @@ class RemoteMangaRepository(
} }
return result return result
} }
private suspend fun <R> MirrorSwitchInterceptor.withMirrorSwitching(block: suspend () -> R): R {
if (!isEnabled) {
return block()
}
val initialMirror = domain
val result = runCatchingCancellable {
block()
}
if (result.isValidResult()) {
return result.getOrThrow()
}
return if (trySwitchMirror(this@RemoteMangaRepository)) {
val newResult = runCatchingCancellable {
block()
}
if (newResult.isValidResult()) {
return newResult.getOrThrow()
} else {
rollback(this@RemoteMangaRepository, initialMirror)
return result.getOrThrow()
}
} else {
result.getOrThrow()
}
}
private fun Result<*>.isValidResult() = exceptionOrNull() !is ParseException
&& (getOrNull() as? Collection<*>)?.isEmpty() != true
} }

View File

@@ -90,6 +90,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val readerPageSwitch: Set<String> val readerPageSwitch: Set<String>
get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS) get() = prefs.getStringSet(KEY_READER_SWITCHERS, null) ?: setOf(PAGE_SWITCH_TAPS)
val isReaderZoomButtonsEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_ZOOM_BUTTONS, false)
val isReaderTapsAdaptive: Boolean val isReaderTapsAdaptive: Boolean
get() = !prefs.getBoolean(KEY_READER_TAPS_LTR, false) get() = !prefs.getBoolean(KEY_READER_TAPS_LTR, false)
@@ -161,7 +164,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getString(KEY_APP_PASSWORD, null) get() = prefs.getString(KEY_APP_PASSWORD, null)
set(value) = prefs.edit { set(value) = prefs.edit {
if (value != null) putString(KEY_APP_PASSWORD, value) else remove( if (value != null) putString(KEY_APP_PASSWORD, value) else remove(
KEY_APP_PASSWORD KEY_APP_PASSWORD,
) )
} }
@@ -267,6 +270,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val isReaderSliderEnabled: Boolean val isReaderSliderEnabled: Boolean
get() = prefs.getBoolean(KEY_READER_SLIDER, true) get() = prefs.getBoolean(KEY_READER_SLIDER, true)
val isReaderKeepScreenOn: Boolean
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
val isImagesProxyEnabled: Boolean val isImagesProxyEnabled: Boolean
get() = prefs.getBoolean(KEY_IMAGES_PROXY, false) get() = prefs.getBoolean(KEY_IMAGES_PROXY, false)
@@ -314,7 +320,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit { set(@FloatRange(from = 0.0, to = 1.0) value) = prefs.edit {
putFloat( putFloat(
KEY_READER_AUTOSCROLL_SPEED, KEY_READER_AUTOSCROLL_SPEED,
value value,
) )
} }
@@ -325,7 +331,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
} }
val policy = NetworkPolicy.from( val policy = NetworkPolicy.from(
prefs.getString(KEY_PAGES_PRELOAD, null), prefs.getString(KEY_PAGES_PRELOAD, null),
NetworkPolicy.NON_METERED NetworkPolicy.NON_METERED,
) )
return policy.isNetworkAllowed(connectivityManager) return policy.isNetworkAllowed(connectivityManager)
} }
@@ -409,6 +415,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_REMOTE_SOURCES = "remote_sources" const val KEY_REMOTE_SOURCES = "remote_sources"
const val KEY_LOCAL_STORAGE = "local_storage" const val KEY_LOCAL_STORAGE = "local_storage"
const val KEY_READER_SWITCHERS = "reader_switchers" const val KEY_READER_SWITCHERS = "reader_switchers"
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
const val KEY_TRACKER_ENABLED = "tracker_enabled" const val KEY_TRACKER_ENABLED = "tracker_enabled"
const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi" const val KEY_TRACKER_WIFI_ONLY = "tracker_wifi"
const val KEY_TRACK_SOURCES = "track_sources" const val KEY_TRACK_SOURCES = "track_sources"
@@ -456,6 +463,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_READER_BAR = "reader_bar" const val KEY_READER_BAR = "reader_bar"
const val KEY_READER_SLIDER = "reader_slider" const val KEY_READER_SLIDER = "reader_slider"
const val KEY_READER_BACKGROUND = "reader_background" const val KEY_READER_BACKGROUND = "reader_background"
const val KEY_READER_SCREEN_ON = "reader_screen_on"
const val KEY_SHORTCUTS = "dynamic_shortcuts" const val KEY_SHORTCUTS = "dynamic_shortcuts"
const val KEY_READER_TAPS_LTR = "reader_taps_ltr" const val KEY_READER_TAPS_LTR = "reader_taps_ltr"
const val KEY_LOCAL_LIST_ORDER = "local_order" const val KEY_LOCAL_LIST_ORDER = "local_order"

View File

@@ -19,13 +19,13 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.withStyledAttributes import androidx.core.content.withStyledAttributes
import androidx.core.view.GravityCompat import androidx.core.view.GravityCompat
import androidx.core.view.ancestors
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.isLayoutReversed import org.koitharu.kotatsu.core.util.ext.isLayoutReversed
import org.koitharu.kotatsu.core.util.ext.parents
import org.koitharu.kotatsu.databinding.FastScrollerBinding import org.koitharu.kotatsu.databinding.FastScrollerBinding
import kotlin.math.roundToInt import kotlin.math.roundToInt
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@@ -522,7 +522,7 @@ class FastScroller @JvmOverloads constructor(
return BubbleSize.entries.getOrNull(ordinal) ?: defaultValue return BubbleSize.entries.getOrNull(ordinal) ?: defaultValue
} }
private fun findValidParent(view: View): ViewGroup? = view.parents.firstNotNullOfOrNull { p -> private fun findValidParent(view: View): ViewGroup? = view.ancestors.firstNotNullOfOrNull { p ->
if (p is FrameLayout || p is ConstraintLayout || p is CoordinatorLayout || p is RelativeLayout) { if (p is FrameLayout || p is ConstraintLayout || p is CoordinatorLayout || p is RelativeLayout) {
p as ViewGroup p as ViewGroup
} else { } else {

View File

@@ -104,6 +104,7 @@ sealed class AdaptiveSheetBehavior {
companion object { companion object {
const val STATE_EXPANDED = SideSheetBehavior.STATE_EXPANDED const val STATE_EXPANDED = SideSheetBehavior.STATE_EXPANDED
const val STATE_COLLAPSED = BottomSheetBehavior.STATE_COLLAPSED
const val STATE_SETTLING = SideSheetBehavior.STATE_SETTLING const val STATE_SETTLING = SideSheetBehavior.STATE_SETTLING
const val STATE_DRAGGING = SideSheetBehavior.STATE_DRAGGING const val STATE_DRAGGING = SideSheetBehavior.STATE_DRAGGING
const val STATE_HIDDEN = SideSheetBehavior.STATE_HIDDEN const val STATE_HIDDEN = SideSheetBehavior.STATE_HIDDEN
@@ -114,10 +115,11 @@ sealed class AdaptiveSheetBehavior {
else -> null else -> null
} }
fun from(lp: CoordinatorLayout.LayoutParams): AdaptiveSheetBehavior? = when (val behavior = lp.behavior) { fun from(lp: CoordinatorLayout.LayoutParams): AdaptiveSheetBehavior? =
is BottomSheetBehavior<*> -> Bottom(behavior) when (val behavior = lp.behavior) {
is SideSheetBehavior<*> -> Side(behavior) is BottomSheetBehavior<*> -> Bottom(behavior)
else -> null is SideSheetBehavior<*> -> Side(behavior)
} else -> null
}
} }
} }

View File

@@ -2,17 +2,19 @@ package org.koitharu.kotatsu.core.ui.sheet
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.InputDevice
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.content.withStyledAttributes import androidx.core.content.withStyledAttributes
import androidx.core.view.ancestors
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.parents
import org.koitharu.kotatsu.databinding.LayoutSheetHeaderAdaptiveBinding import org.koitharu.kotatsu.databinding.LayoutSheetHeaderAdaptiveBinding
class AdaptiveSheetHeaderBar @JvmOverloads constructor( class AdaptiveSheetHeaderBar @JvmOverloads constructor(
@@ -21,7 +23,8 @@ class AdaptiveSheetHeaderBar @JvmOverloads constructor(
@AttrRes defStyleAttr: Int = 0, @AttrRes defStyleAttr: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr), AdaptiveSheetCallback { ) : LinearLayout(context, attrs, defStyleAttr), AdaptiveSheetCallback {
private val binding = LayoutSheetHeaderAdaptiveBinding.inflate(LayoutInflater.from(context), this) private val binding =
LayoutSheetHeaderAdaptiveBinding.inflate(LayoutInflater.from(context), this)
private var sheetBehavior: AdaptiveSheetBehavior? = null private var sheetBehavior: AdaptiveSheetBehavior? = null
var title: CharSequence? var title: CharSequence?
@@ -60,6 +63,28 @@ class AdaptiveSheetHeaderBar @JvmOverloads constructor(
super.onDetachedFromWindow() super.onDetachedFromWindow()
} }
override fun onGenericMotionEvent(event: MotionEvent): Boolean {
val behavior = sheetBehavior ?: return super.onGenericMotionEvent(event)
if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
if (event.actionMasked == MotionEvent.ACTION_SCROLL) {
if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0f) {
behavior.state = if (
behavior is AdaptiveSheetBehavior.Bottom
&& behavior.state == AdaptiveSheetBehavior.STATE_EXPANDED
) {
AdaptiveSheetBehavior.STATE_COLLAPSED
} else {
AdaptiveSheetBehavior.STATE_HIDDEN
}
} else {
behavior.state = AdaptiveSheetBehavior.STATE_EXPANDED
}
return true
}
}
return super.onGenericMotionEvent(event)
}
override fun onStateChanged(sheet: View, newState: Int) { override fun onStateChanged(sheet: View, newState: Int) {
} }
@@ -81,14 +106,9 @@ class AdaptiveSheetHeaderBar @JvmOverloads constructor(
} }
private fun findParentSheetBehavior(): AdaptiveSheetBehavior? { private fun findParentSheetBehavior(): AdaptiveSheetBehavior? {
for (p in parents) { return ancestors.firstNotNullOfOrNull {
val layoutParams = (p as? View)?.layoutParams ((it as? View)?.layoutParams as? CoordinatorLayout.LayoutParams)
if (layoutParams is CoordinatorLayout.LayoutParams) { ?.let { params -> AdaptiveSheetBehavior.from(params) }
AdaptiveSheetBehavior.from(layoutParams)?.let {
return it
}
}
} }
return null
} }
} }

View File

@@ -65,6 +65,9 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
} }
fun show() { fun show() {
if (currentState == STATE_UP) {
return
}
currentAnimator?.cancel() currentAnimator?.cancel()
clearAnimation() clearAnimation()
@@ -77,6 +80,9 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
} }
fun hide() { fun hide() {
if (currentState == STATE_DOWN) {
return
}
currentAnimator?.cancel() currentAnimator?.cancel()
clearAnimation() clearAnimation()
@@ -117,6 +123,7 @@ class SlidingBottomNavigationView @JvmOverloads constructor(
} }
internal class SavedState : AbsSavedState { internal class SavedState : AbsSavedState {
var currentState = STATE_UP var currentState = STATE_UP
var translationY = 0F var translationY = 0F

View File

@@ -0,0 +1,38 @@
package org.koitharu.kotatsu.core.ui.widgets
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.databinding.ViewZoomBinding
class ZoomControl @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : LinearLayout(context, attrs), View.OnClickListener {
private val binding = ViewZoomBinding.inflate(LayoutInflater.from(context), this)
var listener: ZoomControlListener? = null
init {
binding.buttonZoomIn.setOnClickListener(this)
binding.buttonZoomOut.setOnClickListener(this)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_zoom_in -> listener?.onZoomIn()
R.id.button_zoom_out -> listener?.onZoomOut()
}
}
interface ZoomControlListener {
fun onZoomIn()
fun onZoomOut()
}
}

View File

@@ -2,13 +2,14 @@ package org.koitharu.kotatsu.core.util.ext
import android.app.Activity import android.app.Activity
import android.graphics.Rect import android.graphics.Rect
import android.os.Build
import android.view.View import android.view.View
import android.view.View.MeasureSpec import android.view.View.MeasureSpec
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewParent
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.Checkable import android.widget.Checkable
import androidx.core.view.children import androidx.core.view.children
import androidx.core.view.descendants
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
@@ -89,23 +90,8 @@ fun Slider.setValueRounded(newValue: Float) {
value = roundedValue.coerceIn(valueFrom, valueTo) value = roundedValue.coerceIn(valueFrom, valueTo)
} }
fun <T : View> ViewGroup.findViewsByType(clazz: Class<T>): Sequence<T> {
if (childCount == 0) {
return emptySequence()
}
return sequence {
for (view in children) {
if (clazz.isInstance(view)) {
yield(clazz.cast(view)!!)
} else if (view is ViewGroup && view.childCount != 0) {
yieldAll(view.findViewsByType(clazz))
}
}
}
}
fun RecyclerView.invalidateNestedItemDecorations() { fun RecyclerView.invalidateNestedItemDecorations() {
findViewsByType(RecyclerView::class.java).forEach { descendants.filterIsInstance<RecyclerView>().forEach {
it.invalidateItemDecorations() it.invalidateItemDecorations()
} }
} }
@@ -113,15 +99,6 @@ fun RecyclerView.invalidateNestedItemDecorations() {
val View.parentView: ViewGroup? val View.parentView: ViewGroup?
get() = parent as? ViewGroup get() = parent as? ViewGroup
val View.parents: Sequence<ViewParent>
get() = sequence {
var p: ViewParent? = parent
while (p != null) {
yield(p)
p = p.parent
}
}
fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int { fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int {
var result: Int var result: Int
val specMode = MeasureSpec.getMode(measureSpec) val specMode = MeasureSpec.getMode(measureSpec)
@@ -164,3 +141,9 @@ fun BaseProgressIndicator<*>.showOrHide(value: Boolean) {
if (isVisible) hide() if (isVisible) hide()
} }
} }
fun View.setOnContextClickListenerCompat(listener: View.OnLongClickListener) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
setOnContextClickListener(listener::onLongClick)
}
}

View File

@@ -26,7 +26,7 @@ class DoubleMangaLoadUseCase @Inject constructor(
private val recoverUseCase: RecoverMangaUseCase, private val recoverUseCase: RecoverMangaUseCase,
) { ) {
operator fun invoke(manga: Manga): Flow<DoubleManga> = flow<DoubleManga> { operator fun invoke(manga: Manga): Flow<DoubleManga> = flow {
var lastValue: DoubleManga? = null var lastValue: DoubleManga? = null
var emitted = false var emitted = false
invokeImpl(manga).collect { invokeImpl(manga).collect {

View File

@@ -1,5 +1,7 @@
package org.koitharu.kotatsu.details.ui package org.koitharu.kotatsu.details.ui
import android.view.InputDevice
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.View.OnLayoutChangeListener import android.view.View.OnLayoutChangeListener
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
@@ -12,7 +14,7 @@ class ChaptersBottomSheetMediator(
private val behavior: BottomSheetBehavior<*>, private val behavior: BottomSheetBehavior<*>,
) : OnBackPressedCallback(false), ) : OnBackPressedCallback(false),
ActionModeListener, ActionModeListener,
OnLayoutChangeListener { OnLayoutChangeListener, View.OnGenericMotionListener {
private var lockCounter = 0 private var lockCounter = 0
@@ -55,6 +57,20 @@ class ChaptersBottomSheetMediator(
} }
} }
override fun onGenericMotion(v: View?, event: MotionEvent): Boolean {
if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
if (event.actionMasked == MotionEvent.ACTION_SCROLL) {
if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0f) {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED
} else {
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
return true
}
}
return false
}
fun lock() { fun lock() {
lockCounter++ lockCounter++
behavior.isDraggable = lockCounter <= 0 behavior.isDraggable = lockCounter <= 0

View File

@@ -49,6 +49,7 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat
import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe import org.koitharu.kotatsu.core.util.ext.setNavigationIconSafe
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ActivityDetailsBinding import org.koitharu.kotatsu.databinding.ActivityDetailsBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.service.MangaPrefetchService
@@ -89,6 +90,7 @@ class DetailsActivity :
} }
viewBinding.buttonRead.setOnClickListener(this) viewBinding.buttonRead.setOnClickListener(this)
viewBinding.buttonRead.setOnLongClickListener(this) viewBinding.buttonRead.setOnLongClickListener(this)
viewBinding.buttonRead.setOnContextClickListenerCompat(this)
viewBinding.buttonDropdown.setOnClickListener(this) viewBinding.buttonDropdown.setOnClickListener(this)
viewBadge = ViewBadge(viewBinding.buttonRead, this) viewBadge = ViewBadge(viewBinding.buttonRead, this)
@@ -103,6 +105,7 @@ class DetailsActivity :
viewBinding.toolbarChapters?.setNavigationOnClickListener { viewBinding.toolbarChapters?.setNavigationOnClickListener {
behavior.state = BottomSheetBehavior.STATE_COLLAPSED behavior.state = BottomSheetBehavior.STATE_COLLAPSED
} }
viewBinding.toolbarChapters?.setOnGenericMotionListener(bsMediator)
} else { } else {
chaptersMenuProvider = ChaptersMenuProvider(viewModel, null) chaptersMenuProvider = ChaptersMenuProvider(viewModel, null)
addMenuProvider(chaptersMenuProvider) addMenuProvider(chaptersMenuProvider)
@@ -134,13 +137,19 @@ class DetailsActivity :
viewBinding.toolbarChapters?.subtitle = it viewBinding.toolbarChapters?.subtitle = it
viewBinding.textViewSubtitle?.textAndVisible = it viewBinding.textViewSubtitle?.textAndVisible = it
} }
viewModel.isChaptersReversed.observe(this, MenuInvalidator(viewBinding.toolbarChapters ?: this)) viewModel.isChaptersReversed.observe(
this,
MenuInvalidator(viewBinding.toolbarChapters ?: this)
)
viewModel.favouriteCategories.observe(this, MenuInvalidator(this)) viewModel.favouriteCategories.observe(this, MenuInvalidator(this))
viewModel.branches.observe(this) { viewModel.branches.observe(this) {
viewBinding.buttonDropdown.isVisible = it.size > 1 viewBinding.buttonDropdown.isVisible = it.size > 1
} }
viewModel.chapters.observe(this, PrefetchObserver(this)) viewModel.chapters.observe(this, PrefetchObserver(this))
viewModel.onDownloadStarted.observeEvent(this, DownloadStartedObserver(viewBinding.containerDetails)) viewModel.onDownloadStarted.observeEvent(
this,
DownloadStartedObserver(viewBinding.containerDetails)
)
addMenuProvider( addMenuProvider(
DetailsMenuProvider( DetailsMenuProvider(
@@ -243,7 +252,11 @@ class DetailsActivity :
right = insets.right, right = insets.right,
) )
if (insets.bottom > 0) { if (insets.bottom > 0) {
window.setNavigationBarTransparentCompat(this, viewBinding.layoutBottom?.elevation ?: 0f, 0.9f) window.setNavigationBarTransparentCompat(
this,
viewBinding.layoutBottom?.elevation ?: 0f,
0.9f
)
} }
viewBinding.cardChapters?.updateLayoutParams<MarginLayoutParams> { viewBinding.cardChapters?.updateLayoutParams<MarginLayoutParams> {
bottomMargin = insets.bottom + marginEnd bottomMargin = insets.bottom + marginEnd
@@ -265,9 +278,18 @@ class DetailsActivity :
} }
val text = when { val text = when {
!info.isValid -> getString(R.string.loading_) !info.isValid -> getString(R.string.loading_)
info.currentChapter >= 0 -> getString(R.string.chapter_d_of_d, info.currentChapter + 1, info.totalChapters) info.currentChapter >= 0 -> getString(
R.string.chapter_d_of_d,
info.currentChapter + 1,
info.totalChapters
)
info.totalChapters == 0 -> getString(R.string.no_chapters) info.totalChapters == 0 -> getString(R.string.no_chapters)
else -> resources.getQuantityString(R.plurals.chapters, info.totalChapters, info.totalChapters) else -> resources.getQuantityString(
R.plurals.chapters,
info.totalChapters,
info.totalChapters
)
} }
viewBinding.toolbarChapters?.title = text viewBinding.toolbarChapters?.title = text
viewBinding.textViewTitle?.text = text viewBinding.textViewTitle?.text = text
@@ -286,7 +308,12 @@ class DetailsActivity :
append(' ') append(' ')
append(' ') append(' ')
inSpans( inSpans(
ForegroundColorSpan(v.context.getThemeColor(android.R.attr.textColorSecondary, Color.LTGRAY)), ForegroundColorSpan(
v.context.getThemeColor(
android.R.attr.textColorSecondary,
Color.LTGRAY
)
),
RelativeSizeSpan(0.74f), RelativeSizeSpan(0.74f),
) { ) {
append(branch.count.toString()) append(branch.count.toString())
@@ -305,7 +332,8 @@ class DetailsActivity :
val manga = viewModel.manga.value ?: return val manga = viewModel.manga.value ?: return
val chapterId = viewModel.historyInfo.value.history?.chapterId val chapterId = viewModel.historyInfo.value.history?.chapterId
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) { if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
val snackbar = makeSnackbar(getString(R.string.chapter_is_missing), Snackbar.LENGTH_SHORT) val snackbar =
makeSnackbar(getString(R.string.chapter_is_missing), Snackbar.LENGTH_SHORT)
snackbar.show() snackbar.show()
} else { } else {
startActivity( startActivity(
@@ -331,7 +359,10 @@ class DetailsActivity :
view.isVisible = isVisible view.isVisible = isVisible
} }
private fun makeSnackbar(text: CharSequence, @BaseTransientBottomBar.Duration duration: Int): Snackbar { private fun makeSnackbar(
text: CharSequence,
@BaseTransientBottomBar.Duration duration: Int,
): Snackbar {
val sb = Snackbar.make(viewBinding.containerDetails, text, duration) val sb = Snackbar.make(viewBinding.containerDetails, text, duration)
if (viewBinding.layoutBottom?.isVisible == true) { if (viewBinding.layoutBottom?.isVisible == true) {
sb.anchorView = viewBinding.toolbarChapters sb.anchorView = viewBinding.toolbarChapters

View File

@@ -160,21 +160,22 @@ class DetailsFragment :
} }
when (manga.state) { when (manga.state) {
MangaState.FINISHED -> { MangaState.FINISHED -> infoLayout.textViewState.apply {
infoLayout.textViewState.apply { textAndVisible = resources.getString(R.string.state_finished)
textAndVisible = resources.getString(R.string.state_finished) drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_finished)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_finished)
}
} }
MangaState.ONGOING -> { MangaState.ONGOING -> infoLayout.textViewState.apply {
infoLayout.textViewState.apply { textAndVisible = resources.getString(R.string.state_ongoing)
textAndVisible = resources.getString(R.string.state_ongoing) drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_ongoing)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_ongoing)
}
} }
else -> infoLayout.textViewState.isVisible = false MangaState.ABANDONED -> infoLayout.textViewState.apply {
textAndVisible = resources.getString(R.string.state_abandoned)
drawableTop = ContextCompat.getDrawable(context, R.drawable.ic_state_abandoned)
}
null -> infoLayout.textViewState.isVisible = false
} }
if (manga.source == MangaSource.LOCAL) { if (manga.source == MangaSource.LOCAL) {
infoLayout.textViewSource.isVisible = false infoLayout.textViewSource.isVisible = false

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.explore.data
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.Reusable import dagger.Reusable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -11,12 +12,12 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.move
import java.util.Collections import java.util.Collections
import java.util.EnumSet import java.util.EnumSet
import javax.inject.Inject import javax.inject.Inject
@@ -76,14 +77,6 @@ class MangaSourcesRepository @Inject constructor(
} }
} }
suspend fun setSourcesEnabled(sources: Iterable<MangaSource>, isEnabled: Boolean) {
db.withTransaction {
for (s in sources) {
dao.setEnabled(s.name, isEnabled)
}
}
}
suspend fun disableAllSources() { suspend fun disableAllSources() {
db.withTransaction { db.withTransaction {
assimilateNewSources() assimilateNewSources()
@@ -99,46 +92,20 @@ class MangaSourcesRepository @Inject constructor(
} }
} }
suspend fun setPosition(source: MangaSource, index: Int) { fun observeNewSources(): Flow<Set<MangaSource>> = combine(
db.withTransaction { dao.observeAll(),
val all = dao.findAll().toMutableList() observeIsNsfwDisabled(),
val sourceIndex = all.indexOfFirst { x -> x.source == source.name } ) { entities, skipNsfw ->
if (sourceIndex !in all.indices) {
val entity = MangaSourceEntity(
source = source.name,
isEnabled = false,
sortKey = index,
)
all.add(index, entity)
dao.upsert(entity)
} else {
all.move(sourceIndex, index)
}
for ((i, e) in all.withIndex()) {
if (e.sortKey != i) {
dao.setSortKey(e.source, i)
}
}
}
}
fun observeNewSources(): Flow<Set<MangaSource>> = dao.observeAll().map { entities ->
val result = EnumSet.copyOf(remoteSources) val result = EnumSet.copyOf(remoteSources)
for (e in entities) { for (e in entities) {
result.remove(MangaSource(e.source)) result.remove(MangaSource(e.source))
} }
if (skipNsfw) {
result.removeAll { x -> x.isNsfw() }
}
result result
}.distinctUntilChanged() }.distinctUntilChanged()
suspend fun getNewSources(): Set<MangaSource> {
val entities = dao.findAll()
val result = EnumSet.copyOf(remoteSources)
for (e in entities) {
result.remove(MangaSource(e.source))
}
return result
}
suspend fun assimilateNewSources(): Set<MangaSource> { suspend fun assimilateNewSources(): Set<MangaSource> {
val new = getNewSources() val new = getNewSources()
if (new.isEmpty()) { if (new.isEmpty()) {
@@ -153,6 +120,9 @@ class MangaSourcesRepository @Inject constructor(
) )
} }
dao.insertIfAbsent(entities) dao.insertIfAbsent(entities)
if (settings.isNsfwContentDisabled) {
new.removeAll { x -> x.isNsfw() }
}
return new return new
} }
@@ -160,6 +130,15 @@ class MangaSourcesRepository @Inject constructor(
return dao.findAll().isEmpty() return dao.findAll().isEmpty()
} }
private suspend fun getNewSources(): MutableSet<MangaSource> {
val entities = dao.findAll()
val result = EnumSet.copyOf(remoteSources)
for (e in entities) {
result.remove(MangaSource(e.source))
}
return result
}
private fun List<MangaSourceEntity>.toSources(skipNsfwSources: Boolean): List<MangaSource> { private fun List<MangaSourceEntity>.toSources(skipNsfwSources: Boolean): List<MangaSource> {
val result = ArrayList<MangaSource>(size) val result = ArrayList<MangaSource>(size)
for (entity in this) { for (entity in this) {

View File

@@ -7,6 +7,7 @@ import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.Insets import androidx.core.graphics.Insets
import androidx.core.view.updatePadding import androidx.core.view.updatePadding
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
@@ -15,9 +16,11 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity import org.koitharu.kotatsu.bookmarks.ui.BookmarksActivity
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog import org.koitharu.kotatsu.core.ui.dialog.TwoButtonsAlertDialog
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
@@ -28,6 +31,7 @@ import org.koitharu.kotatsu.core.ui.widgets.TipView
import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.databinding.FragmentExploreBinding import org.koitharu.kotatsu.databinding.FragmentExploreBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
@@ -55,6 +59,9 @@ class ExploreFragment :
@Inject @Inject
lateinit var coil: ImageLoader lateinit var coil: ImageLoader
@Inject
lateinit var shortcutManager: AppShortcutManager
private val viewModel by viewModels<ExploreViewModel>() private val viewModel by viewModels<ExploreViewModel>()
private var exploreAdapter: ExploreAdapter? = null private var exploreAdapter: ExploreAdapter? = null
@@ -141,6 +148,8 @@ class ExploreFragment :
override fun onItemLongClick(item: MangaSourceItem, view: View): Boolean { override fun onItemLongClick(item: MangaSourceItem, view: View): Boolean {
val menu = PopupMenu(view.context, view) val menu = PopupMenu(view.context, view)
menu.inflate(R.menu.popup_source) menu.inflate(R.menu.popup_source)
menu.menu.findItem(R.id.action_shortcut)
?.isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(view.context)
menu.setOnMenuItemClickListener(SourceMenuListener(item)) menu.setOnMenuItemClickListener(SourceMenuListener(item))
menu.show() menu.show()
return true return true
@@ -195,6 +204,12 @@ class ExploreFragment :
viewModel.hideSource(sourceItem.source) viewModel.hideSource(sourceItem.source)
} }
R.id.action_shortcut -> {
viewLifecycleScope.launch {
shortcutManager.requestPinShortcut(sourceItem.source)
}
}
else -> return false else -> return false
} }
return true return true

View File

@@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding import org.koitharu.kotatsu.databinding.ItemExploreButtonsBinding
@@ -44,7 +45,12 @@ fun exploreButtonsAD(
if (item.isRandomLoading) { if (item.isRandomLoading) {
val icon = CircularProgressDrawable(context) val icon = CircularProgressDrawable(context)
icon.strokeWidth = context.resources.resolveDp(2f) icon.strokeWidth = context.resources.resolveDp(2f)
icon.setColorSchemeColors(context.getThemeColor(materialR.attr.colorPrimary, Color.DKGRAY)) icon.setColorSchemeColors(
context.getThemeColor(
materialR.attr.colorPrimary,
Color.DKGRAY
)
)
binding.buttonRandom.icon = icon binding.buttonRandom.icon = icon
icon.start() icon.start()
} else { } else {
@@ -88,7 +94,13 @@ fun exploreSourceListItemAD(
listener: OnListItemClickListener<MangaSourceItem>, listener: OnListItemClickListener<MangaSourceItem>,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<MangaSourceItem, ListModel, ItemExploreSourceListBinding>( ) = adapterDelegateViewBinding<MangaSourceItem, ListModel, ItemExploreSourceListBinding>(
{ layoutInflater, parent -> ItemExploreSourceListBinding.inflate(layoutInflater, parent, false) }, { layoutInflater, parent ->
ItemExploreSourceListBinding.inflate(
layoutInflater,
parent,
false
)
},
on = { item, _, _ -> item is MangaSourceItem && !item.isGrid }, on = { item, _, _ -> item is MangaSourceItem && !item.isGrid },
) { ) {
@@ -96,6 +108,7 @@ fun exploreSourceListItemAD(
binding.root.setOnClickListener(eventListener) binding.root.setOnClickListener(eventListener)
binding.root.setOnLongClickListener(eventListener) binding.root.setOnLongClickListener(eventListener)
binding.root.setOnContextClickListenerCompat(eventListener)
bind { bind {
binding.textViewTitle.text = item.source.title binding.textViewTitle.text = item.source.title
@@ -115,7 +128,13 @@ fun exploreSourceGridItemAD(
listener: OnListItemClickListener<MangaSourceItem>, listener: OnListItemClickListener<MangaSourceItem>,
lifecycleOwner: LifecycleOwner, lifecycleOwner: LifecycleOwner,
) = adapterDelegateViewBinding<MangaSourceItem, ListModel, ItemExploreSourceGridBinding>( ) = adapterDelegateViewBinding<MangaSourceItem, ListModel, ItemExploreSourceGridBinding>(
{ layoutInflater, parent -> ItemExploreSourceGridBinding.inflate(layoutInflater, parent, false) }, { layoutInflater, parent ->
ItemExploreSourceGridBinding.inflate(
layoutInflater,
parent,
false
)
},
on = { item, _, _ -> item is MangaSourceItem && item.isGrid }, on = { item, _, _ -> item is MangaSourceItem && item.isGrid },
) { ) {
@@ -123,6 +142,7 @@ fun exploreSourceGridItemAD(
binding.root.setOnClickListener(eventListener) binding.root.setOnClickListener(eventListener)
binding.root.setOnLongClickListener(eventListener) binding.root.setOnLongClickListener(eventListener)
binding.root.setOnContextClickListenerCompat(eventListener)
bind { bind {
binding.textViewTitle.text = item.source.title binding.textViewTitle.text = item.source.title

View File

@@ -131,7 +131,7 @@ class FilterCoordinator @Inject constructor(
observeState(), observeState(),
observeAvailableTags(), observeAvailableTags(),
) { state, available -> ) { state, available ->
val chips = createChipsList(state, available.orEmpty()) val chips = createChipsList(state, available.orEmpty(), 8)
FilterHeaderModel(chips, state.sortOrder, state.tags.isNotEmpty()) FilterHeaderModel(chips, state.sortOrder, state.tags.isNotEmpty())
} }
@@ -157,11 +157,16 @@ class FilterCoordinator @Inject constructor(
private suspend fun createChipsList( private suspend fun createChipsList(
filterState: FilterState, filterState: FilterState,
availableTags: Set<MangaTag>, availableTags: Set<MangaTag>,
limit: Int,
): List<ChipsView.ChipModel> { ): List<ChipsView.ChipModel> {
val selectedTags = filterState.tags.toMutableSet() val selectedTags = filterState.tags.toMutableSet()
var tags = searchRepository.getTagsSuggestion("", 6, repository.source) var tags = if (selectedTags.isEmpty()) {
if (tags.isEmpty()) { searchRepository.getTagsSuggestion("", limit, repository.source)
tags = availableTags.take(6) } else {
searchRepository.getTagsSuggestion(selectedTags).take(limit)
}
if (tags.size < limit) {
tags = tags + availableTags.take(limit - tags.size)
} }
if (tags.isEmpty() && selectedTags.isEmpty()) { if (tags.isEmpty() && selectedTags.isEmpty()) {
return emptyList() return emptyList()

View File

@@ -34,6 +34,9 @@ abstract class MangaListViewModel(
) )
val onDownloadStarted = MutableEventFlow<Unit>() val onDownloadStarted = MutableEventFlow<Unit>()
val isIncognitoModeEnabled: Boolean
get() = settings.isIncognitoModeEnabled
open fun onUpdateFilter(tags: Set<MangaTag>) = Unit open fun onUpdateFilter(tags: Set<MangaTag>) = Unit
abstract fun onRefresh() abstract fun onRefresh()

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import android.view.View
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
@@ -10,6 +11,7 @@ import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemMangaGridBinding import org.koitharu.kotatsu.databinding.ItemMangaGridBinding
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
@@ -28,12 +30,13 @@ fun mangaGridItemAD(
) { ) {
var badge: BadgeDrawable? = null var badge: BadgeDrawable? = null
itemView.setOnClickListener { val eventListener = object : View.OnClickListener, View.OnLongClickListener {
clickListener.onItemClick(item.manga, it) override fun onClick(v: View) = clickListener.onItemClick(item.manga, v)
} override fun onLongClick(v: View): Boolean = clickListener.onItemLongClick(item.manga, v)
itemView.setOnLongClickListener {
clickListener.onItemLongClick(item.manga, it)
} }
itemView.setOnClickListener(eventListener)
itemView.setOnLongClickListener(eventListener)
itemView.setOnContextClickListenerCompat(eventListener)
sizeResolver.attachToView(lifecycleOwner, itemView, binding.textViewTitle, binding.progressView) sizeResolver.attachToView(lifecycleOwner, itemView, binding.textViewTitle, binding.progressView)
bind { payloads -> bind { payloads ->

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding import org.koitharu.kotatsu.databinding.ItemMangaListDetailsBinding
@@ -45,6 +46,7 @@ fun mangaListDetailedItemAD(
} }
itemView.setOnClickListener(listenerAdapter) itemView.setOnClickListener(listenerAdapter)
itemView.setOnLongClickListener(listenerAdapter) itemView.setOnLongClickListener(listenerAdapter)
itemView.setOnContextClickListenerCompat(listenerAdapter)
binding.buttonRead.setOnClickListener(listenerAdapter) binding.buttonRead.setOnClickListener(listenerAdapter)
binding.chipsTags.onChipClickListener = listenerAdapter binding.chipsTags.onChipClickListener = listenerAdapter

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.list.ui.adapter package org.koitharu.kotatsu.list.ui.adapter
import android.view.View
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import coil.ImageLoader import coil.ImageLoader
import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeDrawable
@@ -9,6 +10,7 @@ import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemMangaListBinding import org.koitharu.kotatsu.databinding.ItemMangaListBinding
@@ -25,12 +27,13 @@ fun mangaListItemAD(
) { ) {
var badge: BadgeDrawable? = null var badge: BadgeDrawable? = null
itemView.setOnClickListener { val eventListener = object : View.OnClickListener, View.OnLongClickListener {
clickListener.onItemClick(item.manga, it) override fun onClick(v: View) = clickListener.onItemClick(item.manga, v)
} override fun onLongClick(v: View): Boolean = clickListener.onItemLongClick(item.manga, v)
itemView.setOnLongClickListener {
clickListener.onItemLongClick(item.manga, it)
} }
itemView.setOnClickListener(eventListener)
itemView.setOnLongClickListener(eventListener)
itemView.setOnContextClickListenerCompat(eventListener)
bind { bind {
binding.textViewTitle.text = item.title binding.textViewTitle.text = item.title

View File

@@ -11,6 +11,7 @@ import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.AlertDialogFragment 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.databinding.DialogImportBinding
class ImportDialogFragment : AlertDialogFragment<DialogImportBinding>(), View.OnClickListener { class ImportDialogFragment : AlertDialogFragment<DialogImportBinding>(), View.OnClickListener {
@@ -40,9 +41,13 @@ class ImportDialogFragment : AlertDialogFragment<DialogImportBinding>(), View.On
} }
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { val res = when (v.id) {
R.id.button_file -> importFileCall.launch(arrayOf("*/*")) R.id.button_file -> importFileCall.tryLaunch(arrayOf("*/*"))
R.id.button_dir -> importDirCall.launch(null) R.id.button_dir -> importDirCall.tryLaunch(null)
else -> true
}
if (!res) {
Toast.makeText(v.context, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
} }
} }

View File

@@ -83,6 +83,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
private val viewModel by viewModels<MainViewModel>() private val viewModel by viewModels<MainViewModel>()
private val searchSuggestionViewModel by viewModels<SearchSuggestionViewModel>() private val searchSuggestionViewModel by viewModels<SearchSuggestionViewModel>()
private val closeSearchCallback = CloseSearchCallback() private val closeSearchCallback = CloseSearchCallback()
private val appUpdateDialog = AppUpdateDialog(this)
private lateinit var navigationDelegate: MainNavigationDelegate private lateinit var navigationDelegate: MainNavigationDelegate
private lateinit var appUpdateBadge: OptionsMenuBadgeHelper private lateinit var appUpdateBadge: OptionsMenuBadgeHelper
@@ -111,7 +112,6 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
onFocusChangeListener = this@MainActivity onFocusChangeListener = this@MainActivity
searchSuggestionListener = this@MainActivity searchSuggestionListener = this@MainActivity
} }
window.statusBarColor = ContextCompat.getColor(this, R.color.dim_statusbar)
viewBinding.fab?.setOnClickListener(this) viewBinding.fab?.setOnClickListener(this)
viewBinding.navRail?.headerView?.setOnClickListener(this) viewBinding.navRail?.headerView?.setOnClickListener(this)
@@ -143,6 +143,9 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
viewModel.onFirstStart.observeEvent(this) { viewModel.onFirstStart.observeEvent(this) {
OnboardDialogFragment.show(supportFragmentManager) OnboardDialogFragment.show(supportFragmentManager)
} }
viewModel.isIncognitoMode.observe(this) {
adjustSearchUI(isSearchOpened(), false)
}
searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged) searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged)
} }
@@ -198,8 +201,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
R.id.action_app_update -> { R.id.action_app_update -> {
viewModel.appUpdate.value?.also { viewModel.appUpdate.value?.also {
AppUpdateDialog(this) appUpdateDialog.show(it)
.show(it)
} != null } != null
} }
@@ -239,10 +241,11 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
override fun onQueryClick(query: String, submit: Boolean) { override fun onQueryClick(query: String, submit: Boolean) {
viewBinding.searchView.query = query viewBinding.searchView.query = query
if (submit) { if (submit && query.isNotEmpty()) {
if (query.isNotEmpty()) { startActivity(MultiSearchActivity.newIntent(this, query))
startActivity(MultiSearchActivity.newIntent(this, query)) searchSuggestionViewModel.saveQuery(query)
searchSuggestionViewModel.saveQuery(query) viewBinding.searchView.post {
closeSearchCallback.handleOnBackPressed()
} }
} }
} }
@@ -311,13 +314,11 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
private fun onSearchOpened() { private fun onSearchOpened() {
adjustSearchUI(isOpened = true, animate = true) adjustSearchUI(isOpened = true, animate = true)
closeSearchCallback.isEnabled = true
} }
private fun onSearchClosed() { private fun onSearchClosed() {
viewBinding.searchView.hideKeyboard() viewBinding.searchView.hideKeyboard()
adjustSearchUI(isOpened = false, animate = true) adjustSearchUI(isOpened = false, animate = true)
closeSearchCallback.isEnabled = false
} }
private fun isSearchOpened(): Boolean { private fun isSearchOpened(): Boolean {
@@ -376,13 +377,23 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
val padding = if (isOpened) 0 else resources.getDimensionPixelOffset(R.dimen.margin_normal) val padding = if (isOpened) 0 else resources.getDimensionPixelOffset(R.dimen.margin_normal)
viewBinding.appbar.updatePadding(left = padding, right = padding) viewBinding.appbar.updatePadding(left = padding, right = padding)
adjustFabVisibility(isSearchOpened = isOpened) adjustFabVisibility(isSearchOpened = isOpened)
supportActionBar?.setHomeAsUpIndicator( supportActionBar?.apply {
if (isOpened) materialR.drawable.abc_ic_ab_back_material else materialR.drawable.abc_ic_search_api_material, setHomeAsUpIndicator(
) when {
isOpened -> materialR.drawable.abc_ic_ab_back_material
viewModel.isIncognitoMode.value -> R.drawable.ic_incognito
else -> materialR.drawable.abc_ic_search_api_material
},
)
setHomeActionContentDescription(
if (isOpened) R.string.back else R.string.search,
)
}
viewBinding.searchView.setHintCompat( viewBinding.searchView.setHintCompat(
if (isOpened) R.string.search_hint else R.string.search_manga, if (isOpened) R.string.search_hint else R.string.search_manga,
) )
bottomNav?.showOrHide(!isOpened) bottomNav?.showOrHide(!isOpened)
closeSearchCallback.isEnabled = isOpened
} }
private fun requestNotificationsPermission() { private fun requestNotificationsPermission() {
@@ -394,7 +405,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
ActivityCompat.requestPermissions( ActivityCompat.requestPermissions(
this, this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS), arrayOf(Manifest.permission.POST_NOTIFICATIONS),
1 1,
) )
} }
} }

View File

@@ -72,7 +72,7 @@ class MainNavigationDelegate(
} }
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
navBar.selectedItemId = R.id.nav_history navBar.selectedItemId = firstItem()?.itemId ?: return
} }
fun onCreate(lifecycleOwner: LifecycleOwner, savedInstanceState: Bundle?) { fun onCreate(lifecycleOwner: LifecycleOwner, savedInstanceState: Bundle?) {
@@ -171,7 +171,7 @@ class MainNavigationDelegate(
} }
private fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) { private fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) {
isEnabled = fragment !is HistoryListFragment isEnabled = getItemId(fragment) != firstItem()?.itemId
listeners.forEach { it.onFragmentChanged(fragment, fromUser) } listeners.forEach { it.onFragmentChanged(fragment, fromUser) }
} }

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
import org.koitharu.kotatsu.core.github.AppUpdateRepository import org.koitharu.kotatsu.core.github.AppUpdateRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.NavItem import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
@@ -43,6 +44,12 @@ class MainViewModel @Inject constructor(
initialValue = false, initialValue = false,
) )
val isIncognitoMode = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_INCOGNITO_MODE,
valueProducer = { isIncognitoModeEnabled },
)
val appUpdate = appUpdateRepository.observeAvailableUpdate() val appUpdate = appUpdateRepository.observeAvailableUpdate()
val counters = combine( val counters = combine(

View File

@@ -13,11 +13,11 @@ import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipFile import java.util.zip.ZipFile
import javax.inject.Inject import javax.inject.Inject

View File

@@ -103,11 +103,11 @@ class ReaderActivity :
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivityReaderBinding.inflate(layoutInflater)) setContentView(ActivityReaderBinding.inflate(layoutInflater))
readerManager = ReaderManager(supportFragmentManager, R.id.container) readerManager = ReaderManager(supportFragmentManager, viewBinding.container)
supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayHomeAsUpEnabled(true)
touchHelper = GridTouchHelper(this, this) touchHelper = GridTouchHelper(this, this)
scrollTimer = scrollTimerFactory.create(this, this) scrollTimer = scrollTimerFactory.create(this, this)
controlDelegate = ReaderControlDelegate(settings, this, this) controlDelegate = ReaderControlDelegate(resources, settings, this, this)
viewBinding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected) viewBinding.toolbarBottom.setOnMenuItemClickListener(::onOptionsItemSelected)
viewBinding.slider.setLabelFormatter(PageLabelFormatter()) viewBinding.slider.setLabelFormatter(PageLabelFormatter())
ReaderSliderListener(this, viewModel).attachToSlider(viewBinding.slider) ReaderSliderListener(this, viewModel).attachToSlider(viewBinding.slider)
@@ -137,6 +137,7 @@ class ReaderActivity :
onLoadingStateChanged(viewModel.isLoading.value) onLoadingStateChanged(viewModel.isLoading.value)
} }
viewModel.isScreenshotsBlockEnabled.observe(this, this::setWindowSecure) viewModel.isScreenshotsBlockEnabled.observe(this, this::setWindowSecure)
viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn)
viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged) viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged)
viewModel.isBookmarkAdded.observe(this, this::onBookmarkStateChanged) viewModel.isBookmarkAdded.observe(this, this::onBookmarkStateChanged)
viewModel.onShowToast.observeEvent(this) { msgId -> viewModel.onShowToast.observeEvent(this) { msgId ->
@@ -304,6 +305,14 @@ class ReaderActivity :
} }
} }
private fun setKeepScreenOn(isKeep: Boolean) {
if (isKeep) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
private fun setUiIsVisible(isUiVisible: Boolean) { private fun setUiIsVisible(isUiVisible: Boolean) {
if (viewBinding.appbarTop.isVisible != isUiVisible) { if (viewBinding.appbarTop.isVisible != isUiVisible) {
if (isAnimationsEnabled) { if (isAnimationsEnabled) {
@@ -347,8 +356,8 @@ class ReaderActivity :
readerManager.currentReader?.switchPageBy(delta) readerManager.currentReader?.switchPageBy(delta)
} }
override fun scrollBy(delta: Int): Boolean { override fun scrollBy(delta: Int, smooth: Boolean): Boolean {
return readerManager.currentReader?.scrollBy(delta) ?: false return readerManager.currentReader?.scrollBy(delta, smooth) ?: false
} }
override fun toggleUiVisibility() { override fun toggleUiVisibility() {

View File

@@ -1,16 +1,19 @@
package org.koitharu.kotatsu.reader.ui package org.koitharu.kotatsu.reader.ui
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.res.Resources
import android.view.KeyEvent import android.view.KeyEvent
import android.view.SoundEffectConstants import android.view.SoundEffectConstants
import android.view.View import android.view.View
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.util.GridTouchHelper import org.koitharu.kotatsu.core.util.GridTouchHelper
class ReaderControlDelegate( class ReaderControlDelegate(
resources: Resources,
private val settings: AppSettings, private val settings: AppSettings,
private val listener: OnInteractionListener, private val listener: OnInteractionListener,
owner: LifecycleOwner, owner: LifecycleOwner,
@@ -19,6 +22,7 @@ class ReaderControlDelegate(
private var isTapSwitchEnabled: Boolean = true private var isTapSwitchEnabled: Boolean = true
private var isVolumeKeysSwitchEnabled: Boolean = false private var isVolumeKeysSwitchEnabled: Boolean = false
private var isReaderTapsAdaptive: Boolean = true private var isReaderTapsAdaptive: Boolean = true
private var minScrollDelta = resources.getDimensionPixelSize(R.dimen.reader_scroll_delta_min)
init { init {
owner.lifecycle.addObserver(this) owner.lifecycle.addObserver(this)
@@ -82,8 +86,6 @@ class ReaderControlDelegate(
KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_SPACE,
KeyEvent.KEYCODE_PAGE_DOWN, KeyEvent.KEYCODE_PAGE_DOWN,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN,
KeyEvent.KEYCODE_DPAD_DOWN,
-> { -> {
listener.switchPageBy(1) listener.switchPageBy(1)
true true
@@ -95,8 +97,6 @@ class ReaderControlDelegate(
} }
KeyEvent.KEYCODE_PAGE_UP, KeyEvent.KEYCODE_PAGE_UP,
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP,
KeyEvent.KEYCODE_DPAD_UP,
-> { -> {
listener.switchPageBy(-1) listener.switchPageBy(-1)
true true
@@ -112,6 +112,22 @@ class ReaderControlDelegate(
true true
} }
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP,
KeyEvent.KEYCODE_DPAD_UP -> {
if (!listener.scrollBy(-minScrollDelta, smooth = true)) {
listener.switchPageBy(-1)
}
true
}
KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN,
KeyEvent.KEYCODE_DPAD_DOWN -> {
if (!listener.scrollBy(minScrollDelta, smooth = true)) {
listener.switchPageBy(1)
}
true
}
else -> false else -> false
} }
@@ -139,7 +155,7 @@ class ReaderControlDelegate(
fun switchPageBy(delta: Int) fun switchPageBy(delta: Int)
fun scrollBy(delta: Int): Boolean fun scrollBy(delta: Int, smooth: Boolean): Boolean
fun toggleUiVisibility() fun toggleUiVisibility()

View File

@@ -1,10 +1,12 @@
package org.koitharu.kotatsu.reader.ui package org.koitharu.kotatsu.reader.ui
import androidx.annotation.IdRes import androidx.fragment.app.FragmentContainerView
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.fragment.app.commit import androidx.fragment.app.commit
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.doublepage.DoublePageReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.reversed.ReversedReaderFragment 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.standard.PagerReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment import org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonReaderFragment
@@ -12,19 +14,24 @@ import java.util.EnumMap
class ReaderManager( class ReaderManager(
private val fragmentManager: FragmentManager, private val fragmentManager: FragmentManager,
@IdRes private val containerResId: Int, private val container: FragmentContainerView,
) { ) {
private val modeMap = EnumMap<ReaderMode, Class<out BaseReaderFragment<*>>>(ReaderMode::class.java) private val modeMap = EnumMap<ReaderMode, Class<out BaseReaderFragment<*>>>(ReaderMode::class.java)
init { init {
modeMap[ReaderMode.STANDARD] = PagerReaderFragment::class.java val isTablet = container.resources.getBoolean(R.bool.is_tablet)
modeMap[ReaderMode.STANDARD] = if (isTablet) {
DoublePageReaderFragment::class.java
} else {
PagerReaderFragment::class.java
}
modeMap[ReaderMode.REVERSED] = ReversedReaderFragment::class.java modeMap[ReaderMode.REVERSED] = ReversedReaderFragment::class.java
modeMap[ReaderMode.WEBTOON] = WebtoonReaderFragment::class.java modeMap[ReaderMode.WEBTOON] = WebtoonReaderFragment::class.java
} }
val currentReader: BaseReaderFragment<*>? val currentReader: BaseReaderFragment<*>?
get() = fragmentManager.findFragmentById(containerResId) as? BaseReaderFragment<*> get() = fragmentManager.findFragmentById(container.id) as? BaseReaderFragment<*>
val currentMode: ReaderMode? val currentMode: ReaderMode?
get() { get() {
@@ -36,14 +43,14 @@ class ReaderManager(
val readerClass = requireNotNull(modeMap[newMode]) val readerClass = requireNotNull(modeMap[newMode])
fragmentManager.commit { fragmentManager.commit {
setReorderingAllowed(true) setReorderingAllowed(true)
replace(containerResId, readerClass, null, null) replace(container.id, readerClass, null, null)
} }
} }
fun replace(reader: BaseReaderFragment<*>) { /*fun replace(reader: BaseReaderFragment<*>) {
fragmentManager.commit { fragmentManager.commit {
setReorderingAllowed(true) setReorderingAllowed(true)
replace(containerResId, reader) replace(containerResId, reader)
} }
} }*/
} }

View File

@@ -87,8 +87,7 @@ class ReaderViewModel @Inject constructor(
private var pageSaveJob: Job? = null private var pageSaveJob: Job? = null
private var bookmarkJob: Job? = null private var bookmarkJob: Job? = null
private var stateChangeJob: Job? = null private var stateChangeJob: Job? = null
private val currentState = private val currentState = MutableStateFlow<ReaderState?>(savedStateHandle[ReaderActivity.EXTRA_STATE])
MutableStateFlow<ReaderState?>(savedStateHandle[ReaderActivity.EXTRA_STATE])
private val mangaData = MutableStateFlow(intent.manga?.let { DoubleManga(it) }) private val mangaData = MutableStateFlow(intent.manga?.let { DoubleManga(it) })
private val mangaFlow: Flow<Manga?> private val mangaFlow: Flow<Manga?>
get() = mangaData.map { it?.any } get() = mangaData.map { it?.any }
@@ -114,12 +113,24 @@ class ReaderViewModel @Inject constructor(
valueProducer = { isReaderBarEnabled }, valueProducer = { isReaderBarEnabled },
) )
val isKeepScreenOnEnabled = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_READER_SCREEN_ON,
valueProducer = { isReaderKeepScreenOn },
)
val isWebtoonZoomEnabled = settings.observeAsStateFlow( val isWebtoonZoomEnabled = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default, scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_WEBTOON_ZOOM, key = AppSettings.KEY_WEBTOON_ZOOM,
valueProducer = { isWebtoonZoomEnable }, valueProducer = { isWebtoonZoomEnable },
) )
val isZoomControlEnabled = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_READER_ZOOM_BUTTONS,
valueProducer = { isReaderZoomButtonsEnabled },
)
val readerSettings = ReaderSettings( val readerSettings = ReaderSettings(
parentScope = viewModelScope, parentScope = viewModelScope,
settings = settings, settings = settings,
@@ -320,7 +331,7 @@ class ReaderViewModel @Inject constructor(
mangaData.value = manga mangaData.value = manga
val mangaFlow = doubleMangaLoadUseCase(intent) val mangaFlow = doubleMangaLoadUseCase(intent)
manga = mangaFlow.first { x -> x.any != null } manga = mangaFlow.first { x -> x.any != null }
chaptersLoader.init(viewModelScope, mangaFlow) chaptersLoader.init(viewModelScope, mangaFlow.withErrorHandling())
// determine mode // determine mode
val singleManga = manga.requireAny() val singleManga = manga.requireAny()
// obtain state // obtain state

View File

@@ -96,7 +96,7 @@ class ScrollTimer @AssistedInject constructor(
if (!listener.isReaderResumed()) { if (!listener.isReaderResumed()) {
continue continue
} }
if (!listener.scrollBy(1)) { if (!listener.scrollBy(1, false)) {
accumulator += delayMs accumulator += delayMs
} }
if (accumulator >= pageSwitchDelay) { if (accumulator >= pageSwitchDelay) {

View File

@@ -32,6 +32,9 @@ class ReaderSettings(
val isPagesNumbersEnabled: Boolean val isPagesNumbersEnabled: Boolean
get() = settings.isPagesNumbersEnabled get() = settings.isPagesNumbersEnabled
val isZoomControlsEnabled: Boolean
get() = settings.isReaderZoomButtonsEnabled
fun applyBackground(view: View) { fun applyBackground(view: View) {
val bg = settings.readerBackground val bg = settings.readerBackground
view.background = bg.resolve(view.context) view.background = bg.resolve(view.context)
@@ -74,6 +77,7 @@ class ReaderSettings(
key == AppSettings.KEY_ZOOM_MODE || key == AppSettings.KEY_ZOOM_MODE ||
key == AppSettings.KEY_PAGES_NUMBERS || key == AppSettings.KEY_PAGES_NUMBERS ||
key == AppSettings.KEY_WEBTOON_ZOOM || key == AppSettings.KEY_WEBTOON_ZOOM ||
key == AppSettings.KEY_READER_ZOOM_BUTTONS ||
key == AppSettings.KEY_READER_BACKGROUND key == AppSettings.KEY_READER_BACKGROUND
) { ) {
notifyChanged() notifyChanged()

View File

@@ -13,7 +13,7 @@ import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
abstract class BasePageHolder<B : ViewBinding>( abstract class BasePageHolder<B : ViewBinding>(
protected val binding: B, protected val binding: B,
loader: PageLoader, loader: PageLoader,
private val settings: ReaderSettings, protected val settings: ReaderSettings,
networkState: NetworkState, networkState: NetworkState,
exceptionResolver: ExceptionResolver, exceptionResolver: ExceptionResolver,
) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback { ) : RecyclerView.ViewHolder(binding.root), PageHolderDelegate.Callback {

View File

@@ -66,7 +66,7 @@ abstract class BaseReaderFragment<B : ViewBinding> : BaseFragment<B>() {
abstract fun switchPageTo(position: Int, smooth: Boolean) abstract fun switchPageTo(position: Int, smooth: Boolean)
open fun scrollBy(delta: Int): Boolean = false open fun scrollBy(delta: Int, smooth: Boolean): Boolean = false
abstract fun getCurrentState(): ReaderState? abstract fun getCurrentState(): ReaderState?

View File

@@ -0,0 +1,48 @@
package org.koitharu.kotatsu.reader.ui.pager.doublepage
import android.graphics.PointF
import android.view.Gravity
import android.widget.FrameLayout
import androidx.lifecycle.LifecycleOwner
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import org.koitharu.kotatsu.reader.ui.pager.standard.PageHolder
class DoublePageHolder(
owner: LifecycleOwner,
binding: ItemPageBinding,
loader: PageLoader,
settings: ReaderSettings,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : PageHolder(owner, binding, loader, settings, networkState, exceptionResolver) {
private val isEven: Boolean
get() = bindingAdapterPosition and 1 == 0
override fun onBind(data: ReaderPage) {
super.onBind(data)
(binding.textViewNumber.layoutParams as FrameLayout.LayoutParams)
.gravity = (if (isEven) Gravity.START else Gravity.END) or Gravity.BOTTOM
}
override fun onImageShowing(settings: ReaderSettings) {
with(binding.ssiv) {
maxScale = 2f * maxOf(
width / sWidth.toFloat(),
height / sHeight.toFloat(),
)
binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter()
minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CENTER_INSIDE
setScaleAndCenter(
minScale,
PointF(if (isEven) sWidth.toFloat() else 0f, 0f),
)
}
}
}

View File

@@ -0,0 +1,19 @@
package org.koitharu.kotatsu.reader.ui.pager.doublepage
import android.content.Context
import android.util.AttributeSet
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
class DoublePageLayoutManager(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int,
defStyleRes: Int,
) : LinearLayoutManager(context, attrs, defStyleAttr, defStyleRes) {
override fun checkLayoutParams(lp: RecyclerView.LayoutParams?): Boolean {
lp?.width = width / 2
return super.checkLayoutParams(lp)
}
}

View File

@@ -0,0 +1,128 @@
package org.koitharu.kotatsu.reader.ui.pager.doublepage
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition
import org.koitharu.kotatsu.databinding.FragmentReaderDoubleBinding
import org.koitharu.kotatsu.parsers.util.toIntUp
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
@AndroidEntryPoint
class DoublePageReaderFragment : BaseReaderFragment<FragmentReaderDoubleBinding>() {
@Inject
lateinit var networkState: NetworkState
@Inject
lateinit var pageLoader: PageLoader
override fun onCreateViewBinding(
inflater: LayoutInflater,
container: ViewGroup?,
) = FragmentReaderDoubleBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(
binding: FragmentReaderDoubleBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState)
with(binding.recyclerView) {
adapter = readerAdapter
addOnScrollListener(PageScrollListener())
DoublePageSnapHelper().attachToRecyclerView(this)
}
}
override fun onDestroyView() {
requireViewBinding().recyclerView.adapter = null
super.onDestroyView()
}
override suspend fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) =
coroutineScope {
val items = async {
requireAdapter().setItems(pages)
yield()
}
if (pendingState != null) {
val position = pages.indexOfFirst {
it.chapterId == pendingState.chapterId && it.index == pendingState.page
}
items.await()
if (position != -1) {
requireViewBinding().recyclerView.firstVisibleItemPosition = position or 1
notifyPageChanged(position)
} else {
Snackbar.make(requireView(), R.string.not_found_404, Snackbar.LENGTH_SHORT)
.show()
}
} else {
items.await()
}
}
override fun onCreateAdapter() = DoublePagesAdapter(
lifecycleOwner = viewLifecycleOwner,
loader = pageLoader,
settings = viewModel.readerSettings,
networkState = networkState,
exceptionResolver = exceptionResolver,
)
override fun switchPageBy(delta: Int) {
switchPageTo((requireViewBinding().recyclerView.currentItem() + delta) or 1, delta.absoluteValue > 1)
}
override fun switchPageTo(position: Int, smooth: Boolean) {
requireViewBinding().recyclerView.firstVisibleItemPosition = position or 1
}
override fun getCurrentState(): ReaderState? = viewBinding?.run {
val adapter = recyclerView.adapter as? BaseReaderAdapter<*>
val page = adapter?.getItemOrNull(recyclerView.currentItem()) ?: return@run null
ReaderState(
chapterId = page.chapterId,
page = page.index,
scroll = 0,
)
}
private fun notifyPageChanged(page: Int) {
viewModel.onCurrentPageChanged(page)
}
private fun RecyclerView.currentItem(): Int {
val lm = layoutManager as LinearLayoutManager
return ((lm.findFirstVisibleItemPosition() + lm.findLastVisibleItemPosition()) / 2f).toIntUp()
}
private inner class PageScrollListener : RecyclerView.OnScrollListener() {
private var lastPage = RecyclerView.NO_POSITION
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val page = recyclerView.currentItem()
if (page != lastPage) {
lastPage = page
notifyPageChanged(page)
}
}
}
}

View File

@@ -0,0 +1,280 @@
package org.koitharu.kotatsu.reader.ui.pager.doublepage
import android.util.DisplayMetrics
import android.view.View
import android.view.animation.Interpolator
import android.widget.Scroller
import androidx.core.view.ViewCompat
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.OrientationHelper
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.SmoothScroller.ScrollVectorProvider
import androidx.recyclerview.widget.SnapHelper
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
class DoublePageSnapHelper : SnapHelper() {
private lateinit var recyclerView: RecyclerView
// Total number of items in a block of view in the RecyclerView
private var blockSize = 2
// Maximum number of positions to move on a fling.
private var maxPositionsToMove = 0
// Width of a RecyclerView item if orientation is horizontal; height of the item if vertical
private var itemDimension = 0
// Maxim blocks to move during most vigorous fling.
private val maxFlingBlocks = 2
// When snapping, used to determine direction of snap.
private var priorFirstPosition = RecyclerView.NO_POSITION
// Our private scroller
private var scroller: Scroller? = null
// Horizontal/vertical layout helper
private lateinit var orientationHelper: OrientationHelper
// LTR/RTL helper
private lateinit var layoutDirectionHelper: LayoutDirectionHelper
private val snapInterpolator = Interpolator { input ->
var t = input
t -= 1.0f
t * t * t + 1.0f
}
@Throws(IllegalStateException::class)
override fun attachToRecyclerView(target: RecyclerView?) {
if (target != null) {
recyclerView = target
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
check(layoutManager.canScrollHorizontally()) { "RecyclerView must be scrollable" }
orientationHelper = OrientationHelper.createHorizontalHelper(layoutManager)
layoutDirectionHelper = LayoutDirectionHelper(ViewCompat.getLayoutDirection(recyclerView))
scroller = Scroller(target.context, snapInterpolator)
initItemDimensionIfNeeded(layoutManager)
}
super.attachToRecyclerView(recyclerView)
}
override fun calculateDistanceToFinalSnap(
layoutManager: RecyclerView.LayoutManager,
targetView: View
): IntArray {
val out = IntArray(2)
if (layoutManager.canScrollHorizontally()) {
out[0] = layoutDirectionHelper.getScrollToAlignView(targetView)
}
if (layoutManager.canScrollVertically()) {
out[1] = layoutDirectionHelper.getScrollToAlignView(targetView)
}
return out
}
// We are flinging and need to know where we are heading.
override fun findTargetSnapPosition(
layoutManager: RecyclerView.LayoutManager,
velocityX: Int, velocityY: Int
): Int {
val lm = layoutManager as LinearLayoutManager
initItemDimensionIfNeeded(layoutManager)
scroller!!.fling(0, 0, velocityX, velocityY, Int.MIN_VALUE, Int.MAX_VALUE, Int.MIN_VALUE, Int.MAX_VALUE)
if (velocityX != 0) {
return layoutDirectionHelper
.getPositionsToMove(lm, scroller!!.finalX, itemDimension)
}
return if (velocityY != 0) {
layoutDirectionHelper
.getPositionsToMove(lm, scroller!!.finalY, itemDimension)
} else RecyclerView.NO_POSITION
}
// We have scrolled to the neighborhood where we will snap. Determine the snap position.
override fun findSnapView(layoutManager: RecyclerView.LayoutManager): View? {
// Snap to a view that is either 1) toward the bottom of the data and therefore on screen,
// or, 2) toward the top of the data and may be off-screen.
val snapPos: Int = calcTargetPosition(layoutManager as LinearLayoutManager)
return if (snapPos == RecyclerView.NO_POSITION) null else layoutManager.findViewByPosition(snapPos)
}
// Does the heavy lifting for findSnapView.
private fun calcTargetPosition(layoutManager: LinearLayoutManager): Int {
val snapPos: Int
val firstVisiblePos = layoutManager.findFirstVisibleItemPosition()
if (firstVisiblePos == RecyclerView.NO_POSITION) {
return RecyclerView.NO_POSITION
}
initItemDimensionIfNeeded(layoutManager)
if (firstVisiblePos >= priorFirstPosition) {
// Scrolling toward bottom of data
val firstCompletePosition = layoutManager.findFirstCompletelyVisibleItemPosition()
snapPos = if (firstCompletePosition != RecyclerView.NO_POSITION
&& firstCompletePosition % blockSize == 0
) {
firstCompletePosition
} else {
roundDownToBlockSize(firstVisiblePos + blockSize)
}
} else {
// Scrolling toward top of data
snapPos = roundDownToBlockSize(firstVisiblePos)
// Check to see if target view exists. If it doesn't, force a smooth scroll.
// SnapHelper only snaps to existing views and will not scroll to a non-existent one.
// If limiting fling to single block, then the following is not needed since the
// views are likely to be in the RecyclerView pool.
if (layoutManager.findViewByPosition(snapPos) == null) {
val toScroll: IntArray = layoutDirectionHelper.calculateDistanceToScroll(layoutManager, snapPos)
recyclerView.smoothScrollBy(toScroll[0], toScroll[1], snapInterpolator)
}
}
priorFirstPosition = firstVisiblePos
return snapPos
}
private fun initItemDimensionIfNeeded(layoutManager: RecyclerView.LayoutManager) {
if (itemDimension != 0) {
return
}
val child: View = layoutManager.getChildAt(0) ?: return
if (layoutManager.canScrollHorizontally()) {
itemDimension = child.width
blockSize = getSpanCount(layoutManager) * (recyclerView.width / itemDimension)
} else if (layoutManager.canScrollVertically()) {
itemDimension = child.height
blockSize = getSpanCount(layoutManager) * (recyclerView.height / itemDimension)
}
maxPositionsToMove = blockSize * maxFlingBlocks
}
private fun getSpanCount(layoutManager: RecyclerView.LayoutManager): Int {
return if (layoutManager is GridLayoutManager) layoutManager.spanCount else 1
}
private fun roundDownToBlockSize(trialPosition: Int): Int {
return trialPosition - trialPosition % blockSize
}
private fun roundUpToBlockSize(trialPosition: Int): Int {
return roundDownToBlockSize(trialPosition + blockSize - 1)
}
override fun createScroller(layoutManager: RecyclerView.LayoutManager): RecyclerView.SmoothScroller? {
return if (layoutManager !is ScrollVectorProvider) {
null
} else object : LinearSmoothScroller(recyclerView.context) {
override fun onTargetFound(targetView: View, state: RecyclerView.State, action: Action) {
val snapDistances = calculateDistanceToFinalSnap(
recyclerView.layoutManager!!,
targetView,
)
val dx = snapDistances[0]
val dy = snapDistances[1]
val time = calculateTimeForDeceleration(
max(abs(dx.toDouble()), abs(dy.toDouble()))
.toInt(),
)
if (time > 0) {
action.update(dx, dy, time, snapInterpolator)
}
}
override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics): Float {
return 40f / displayMetrics.densityDpi
}
}
}
/*
Helper class that handles calculations for LTR and RTL layouts.
*/
private inner class LayoutDirectionHelper(direction: Int) {
// Is the layout an RTL one?
private val mIsRTL: Boolean
init {
mIsRTL = direction == View.LAYOUT_DIRECTION_RTL
}
/*
Calculate the amount of scroll needed to align the target view with the layout edge.
*/
fun getScrollToAlignView(targetView: View?): Int {
return if (mIsRTL) orientationHelper.getDecoratedEnd(targetView) - recyclerView.width else orientationHelper.getDecoratedStart(
targetView,
)
}
/**
* Calculate the distance to final snap position when the view corresponding to the snap
* position is not currently available.
*
* @param layoutManager LinearLayoutManager or descendant class
* @param targetPos - Adapter position to snap to
* @return int[2] {x-distance in pixels, y-distance in pixels}
*/
fun calculateDistanceToScroll(layoutManager: LinearLayoutManager, targetPos: Int): IntArray {
val out = IntArray(2)
val firstVisiblePos = layoutManager.findFirstVisibleItemPosition()
if (layoutManager.canScrollHorizontally()) {
if (targetPos <= firstVisiblePos) { // scrolling toward top of data
if (mIsRTL) {
val lastView = layoutManager.findViewByPosition(layoutManager.findLastVisibleItemPosition())
out[0] = (orientationHelper.getDecoratedEnd(lastView)
+ (firstVisiblePos - targetPos) * itemDimension)
} else {
val firstView = layoutManager.findViewByPosition(firstVisiblePos)
out[0] = (orientationHelper.getDecoratedStart(firstView)
- (firstVisiblePos - targetPos) * itemDimension)
}
}
}
if (layoutManager.canScrollVertically()) {
if (targetPos <= firstVisiblePos) { // scrolling toward top of data
val firstView = layoutManager.findViewByPosition(firstVisiblePos)
out[1] = firstView!!.top - (firstVisiblePos - targetPos) * itemDimension
}
}
return out
}
/*
Calculate the number of positions to move in the RecyclerView given a scroll amount
and the size of the items to be scrolled. Return integral multiple of mBlockSize not
equal to zero.
*/
fun getPositionsToMove(llm: LinearLayoutManager, scroll: Int, itemSize: Int): Int {
var positionsToMove: Int
positionsToMove = roundUpToBlockSize(abs((scroll.toDouble()) / itemSize).roundToInt())
if (positionsToMove < blockSize) {
// Must move at least one block
positionsToMove = blockSize
} else if (positionsToMove > maxPositionsToMove) {
// Clamp number of positions to move, so we don't get wild flinging.
positionsToMove = maxPositionsToMove
}
if (scroll < 0) {
positionsToMove *= -1
}
if (mIsRTL) {
positionsToMove *= -1
}
return if (layoutDirectionHelper.isDirectionToBottom(scroll < 0)) {
// Scrolling toward the bottom of data.
roundDownToBlockSize(llm.findFirstVisibleItemPosition()) + positionsToMove
} else roundDownToBlockSize(llm.findLastVisibleItemPosition()) + positionsToMove
// Scrolling toward the top of the data.
}
fun isDirectionToBottom(velocityNegative: Boolean): Boolean {
return if (mIsRTL) velocityNegative else !velocityNegative
}
}
}

View File

@@ -0,0 +1,35 @@
package org.koitharu.kotatsu.reader.ui.pager.doublepage
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.lifecycle.LifecycleOwner
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.databinding.ItemPageBinding
import org.koitharu.kotatsu.reader.domain.PageLoader
import org.koitharu.kotatsu.reader.ui.config.ReaderSettings
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
class DoublePagesAdapter(
private val lifecycleOwner: LifecycleOwner,
loader: PageLoader,
settings: ReaderSettings,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) : BaseReaderAdapter<DoublePageHolder>(loader, settings, networkState, exceptionResolver) {
override fun onCreateViewHolder(
parent: ViewGroup,
loader: PageLoader,
settings: ReaderSettings,
networkState: NetworkState,
exceptionResolver: ExceptionResolver,
) = DoublePageHolder(
owner = lifecycleOwner,
binding = ItemPageBinding.inflate(LayoutInflater.from(parent.context), parent, false),
loader = loader,
settings = settings,
networkState = networkState,
exceptionResolver = exceptionResolver,
)
}

View File

@@ -1,7 +1,12 @@
package org.koitharu.kotatsu.reader.ui.pager.reversed package org.koitharu.kotatsu.reader.ui.pager.reversed
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.InputDevice
import android.view.KeyEvent
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.children import androidx.core.view.children
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
@@ -23,12 +28,15 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderAdapter
import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage 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.NoAnimPageTransformer
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerEventSupplier
import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment import org.koitharu.kotatsu.reader.ui.pager.standard.PagerReaderFragment
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.math.sign
@AndroidEntryPoint @AndroidEntryPoint
class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>() { class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>(),
View.OnGenericMotionListener {
@Inject @Inject
lateinit var networkState: NetworkState lateinit var networkState: NetworkState
@@ -47,6 +55,11 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
adapter = readerAdapter adapter = readerAdapter
offscreenPageLimit = 2 offscreenPageLimit = 2
doOnPageChanged(::notifyPageChanged) doOnPageChanged(::notifyPageChanged)
setOnGenericMotionListener(this@ReversedReaderFragment)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
recyclerView?.defaultFocusHighlightEnabled = false
}
PagerEventSupplier(this).attach()
} }
viewModel.pageAnimation.observe(viewLifecycleOwner) { viewModel.pageAnimation.observe(viewLifecycleOwner) {
@@ -69,6 +82,20 @@ class ReversedReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>
super.onDestroyView() 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 onCreateAdapter() = ReversedPagesAdapter( override fun onCreateAdapter() = ReversedPagesAdapter(
lifecycleOwner = viewLifecycleOwner, lifecycleOwner = viewLifecycleOwner,
loader = pageLoader, loader = pageLoader,

View File

@@ -40,6 +40,12 @@ open class PageHolder(
@Suppress("LeakingThis") @Suppress("LeakingThis")
bindingInfo.buttonErrorDetails.setOnClickListener(this) bindingInfo.buttonErrorDetails.setOnClickListener(this)
binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled binding.textViewNumber.isVisible = settings.isPagesNumbersEnabled
binding.zoomControl.listener = SsivZoomListener(binding.ssiv)
}
override fun onConfigChanged() {
super.onConfigChanged()
binding.zoomControl.isVisible = settings.isZoomControlsEnabled
} }
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")

View File

@@ -0,0 +1,24 @@
package org.koitharu.kotatsu.reader.ui.pager.standard
import android.view.KeyEvent
import android.view.View
import android.view.ViewGroup
import androidx.core.view.children
import androidx.viewpager2.widget.ViewPager2
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.core.util.ext.recyclerView
class PagerEventSupplier(private val pager: ViewPager2) : View.OnKeyListener {
fun attach() {
pager.recyclerView?.setOnKeyListener(this)
}
override fun onKey(v: View?, keyCode: Int, event: KeyEvent?): Boolean {
val rootView = pager.recyclerView?.findViewHolderForAdapterPosition(pager.currentItem)?.itemView as? ViewGroup
?: return false
return rootView.children.firstNotNullOfOrNull { x ->
x as? SubsamplingScaleImageView
}?.dispatchKeyEvent(event) == true
}
}

View File

@@ -1,7 +1,12 @@
package org.koitharu.kotatsu.reader.ui.pager.standard package org.koitharu.kotatsu.reader.ui.pager.standard
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.InputDevice
import android.view.KeyEvent
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.children import androidx.core.view.children
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
@@ -24,9 +29,11 @@ import org.koitharu.kotatsu.reader.ui.pager.BaseReaderFragment
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
import kotlin.math.sign
@AndroidEntryPoint @AndroidEntryPoint
class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>() { class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>(),
View.OnGenericMotionListener {
@Inject @Inject
lateinit var networkState: NetworkState lateinit var networkState: NetworkState
@@ -39,12 +46,20 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>()
container: ViewGroup?, container: ViewGroup?,
) = FragmentReaderStandardBinding.inflate(inflater, container, false) ) = FragmentReaderStandardBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: FragmentReaderStandardBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(
binding: FragmentReaderStandardBinding,
savedInstanceState: Bundle?,
) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
with(binding.pager) { with(binding.pager) {
adapter = readerAdapter adapter = readerAdapter
offscreenPageLimit = 2 offscreenPageLimit = 2
doOnPageChanged(::notifyPageChanged) doOnPageChanged(::notifyPageChanged)
setOnGenericMotionListener(this@PagerReaderFragment)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
recyclerView?.defaultFocusHighlightEnabled = false
}
PagerEventSupplier(this).attach()
} }
viewModel.pageAnimation.observe(viewLifecycleOwner) { viewModel.pageAnimation.observe(viewLifecycleOwner) {
@@ -67,28 +82,43 @@ class PagerReaderFragment : BaseReaderFragment<FragmentReaderStandardBinding>()
super.onDestroyView() super.onDestroyView()
} }
override suspend fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) = coroutineScope { override fun onGenericMotion(v: View?, event: MotionEvent): Boolean {
val items = async { if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
requireAdapter().setItems(pages) if (event.actionMasked == MotionEvent.ACTION_SCROLL) {
yield() val axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
} val withCtrl = event.metaState and KeyEvent.META_CTRL_MASK != 0
if (pendingState != null) { if (!withCtrl) {
val position = pages.indexOfFirst { switchPageBy(-axisValue.sign.toInt())
it.chapterId == pendingState.chapterId && it.index == pendingState.page return true
}
} }
items.await()
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.await()
} }
return false
} }
override suspend fun onPagesChanged(pages: List<ReaderPage>, pendingState: ReaderState?) =
coroutineScope {
val items = async {
requireAdapter().setItems(pages)
yield()
}
if (pendingState != null) {
val position = pages.indexOfFirst {
it.chapterId == pendingState.chapterId && it.index == pendingState.page
}
items.await()
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.await()
}
}
override fun onCreateAdapter() = PagesAdapter( override fun onCreateAdapter() = PagesAdapter(
lifecycleOwner = viewLifecycleOwner, lifecycleOwner = viewLifecycleOwner,
loader = pageLoader, loader = pageLoader,

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.reader.ui.pager.standard
import android.view.animation.DecelerateInterpolator
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.core.ui.widgets.ZoomControl
class SsivZoomListener(
private val ssiv: SubsamplingScaleImageView,
) : ZoomControl.ZoomControlListener {
override fun onZoomIn() {
scaleBy(1.2f)
}
override fun onZoomOut() {
scaleBy(0.8f)
}
private fun scaleBy(factor: Float) {
val center = ssiv.getCenter() ?: return
val newScale = ssiv.scale * factor
ssiv.animateScaleAndCenter(newScale, center)?.apply {
withDuration(ssiv.resources.getInteger(android.R.integer.config_shortAnimTime).toLong())
withInterpolator(DecelerateInterpolator())
start()
}
}
}

View File

@@ -12,8 +12,8 @@ class WebtoonFrameLayout @JvmOverloads constructor(
@AttrRes defStyleAttr: Int = 0, @AttrRes defStyleAttr: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr) { ) : FrameLayout(context, attrs, defStyleAttr) {
val target by lazy(LazyThreadSafetyMode.NONE) { val target: WebtoonImageView by lazy(LazyThreadSafetyMode.NONE) {
findViewById<WebtoonImageView>(R.id.ssiv) findViewById(R.id.ssiv)
} }
fun dispatchVerticalScroll(dy: Int): Int { fun dispatchVerticalScroll(dy: Int): Int {

View File

@@ -3,9 +3,9 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.content.Context import android.content.Context
import android.graphics.PointF import android.graphics.PointF
import android.util.AttributeSet import android.util.AttributeSet
import androidx.core.view.ancestors
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.core.util.ext.parents
import org.koitharu.kotatsu.parsers.util.toIntUp import org.koitharu.kotatsu.parsers.util.toIntUp
private const val SCROLL_UNKNOWN = -1 private const val SCROLL_UNKNOWN = -1
@@ -93,7 +93,7 @@ class WebtoonImageView @JvmOverloads constructor(
if (oldh == h || oldw == 0 || oldh == 0 || scrollRange == SCROLL_UNKNOWN) return if (oldh == h || oldw == 0 || oldh == 0 || scrollRange == SCROLL_UNKNOWN) return
computeScrollRange() computeScrollRange()
val container = parents.firstNotNullOfOrNull { it as? WebtoonFrameLayout } ?: return val container = ancestors.firstNotNullOfOrNull { it as? WebtoonFrameLayout } ?: return
val parentHeight = parentHeight() val parentHeight = parentHeight()
if (scrollPos != 0 && container.bottom < parentHeight) { if (scrollPos != 0 && container.bottom < parentHeight) {
scrollTo(scrollRange) scrollTo(scrollRange)
@@ -115,6 +115,6 @@ class WebtoonImageView @JvmOverloads constructor(
} }
private fun parentHeight(): Int { private fun parentHeight(): Int {
return parents.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0 return ancestors.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0
} }
} }

View File

@@ -3,11 +3,13 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.DecelerateInterpolator
import androidx.core.view.isVisible
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.yield import kotlinx.coroutines.yield
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.os.NetworkState
@@ -31,7 +33,7 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
@Inject @Inject
lateinit var pageLoader: PageLoader lateinit var pageLoader: PageLoader
private val scrollInterpolator = AccelerateDecelerateInterpolator() private val scrollInterpolator = DecelerateInterpolator()
override fun onCreateViewBinding( override fun onCreateViewBinding(
inflater: LayoutInflater, inflater: LayoutInflater,
@@ -45,10 +47,15 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
adapter = readerAdapter adapter = readerAdapter
addOnPageScrollListener(PageScrollListener()) addOnPageScrollListener(PageScrollListener())
} }
binding.zoomControl.listener = binding.frame
viewModel.isWebtoonZoomEnabled.observe(viewLifecycleOwner) { viewModel.isWebtoonZoomEnabled.observe(viewLifecycleOwner) {
binding.frame.isZoomEnable = it binding.frame.isZoomEnable = it
} }
combine(viewModel.isWebtoonZoomEnabled, viewModel.isZoomControlEnabled, Boolean::and)
.observe(viewLifecycleOwner) {
binding.zoomControl.isVisible = it
}
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -122,8 +129,12 @@ class WebtoonReaderFragment : BaseReaderFragment<FragmentReaderWebtoonBinding>()
requireViewBinding().recyclerView.firstVisibleItemPosition = position requireViewBinding().recyclerView.firstVisibleItemPosition = position
} }
override fun scrollBy(delta: Int): Boolean { override fun scrollBy(delta: Int, smooth: Boolean): Boolean {
requireViewBinding().recyclerView.nestedScrollBy(0, delta) if (smooth && isAnimationEnabled()) {
requireViewBinding().recyclerView.smoothScrollBy(0, delta, scrollInterpolator)
} else {
requireViewBinding().recyclerView.nestedScrollBy(0, delta)
}
return true return true
} }

View File

@@ -7,12 +7,19 @@ import android.graphics.Rect
import android.graphics.RectF import android.graphics.RectF
import android.util.AttributeSet import android.util.AttributeSet
import android.view.GestureDetector import android.view.GestureDetector
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent import android.view.MotionEvent
import android.view.ScaleGestureDetector import android.view.ScaleGestureDetector
import android.view.ViewConfiguration
import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.DecelerateInterpolator
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.OverScroller import android.widget.OverScroller
import androidx.core.view.GestureDetectorCompat import androidx.core.view.GestureDetectorCompat
import androidx.core.view.ViewConfigurationCompat
import org.koitharu.kotatsu.core.ui.widgets.ZoomControl
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
private const val MAX_SCALE = 2.5f private const val MAX_SCALE = 2.5f
private const val MIN_SCALE = 0.5f private const val MIN_SCALE = 0.5f
@@ -21,7 +28,9 @@ class WebtoonScalingFrame @JvmOverloads constructor(
context: Context, context: Context,
attrs: AttributeSet? = null, attrs: AttributeSet? = null,
defStyles: Int = 0, defStyles: Int = 0,
) : FrameLayout(context, attrs, defStyles), ScaleGestureDetector.OnScaleGestureListener { ) : FrameLayout(context, attrs, defStyles),
ScaleGestureDetector.OnScaleGestureListener,
ZoomControl.ZoomControlListener {
private val targetChild by lazy(LazyThreadSafetyMode.NONE) { getChildAt(0) as WebtoonRecyclerView } private val targetChild by lazy(LazyThreadSafetyMode.NONE) { getChildAt(0) as WebtoonRecyclerView }
@@ -40,6 +49,7 @@ class WebtoonScalingFrame @JvmOverloads constructor(
private var halfHeight = 0f private var halfHeight = 0f
private val translateBounds = RectF() private val translateBounds = RectF()
private val targetHitRect = Rect() private val targetHitRect = Rect()
private var animator: ValueAnimator? = null
var isZoomEnable = true var isZoomEnable = true
set(value) { set(value) {
@@ -77,10 +87,79 @@ class WebtoonScalingFrame @JvmOverloads constructor(
return super.dispatchTouchEvent(ev) return super.dispatchTouchEvent(ev)
} }
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { override fun onGenericMotionEvent(event: MotionEvent): Boolean {
super.onMeasure(widthMeasureSpec, heightMeasureSpec) if (isZoomEnable && event.source and InputDevice.SOURCE_CLASS_POINTER != 0) {
halfWidth = measuredWidth / 2f if (event.actionMasked == MotionEvent.ACTION_SCROLL) {
halfHeight = measuredHeight / 2f val withCtrl = event.metaState and KeyEvent.META_CTRL_MASK != 0
if (withCtrl) {
val axisValue =
event.getAxisValue(MotionEvent.AXIS_VSCROLL) * ViewConfigurationCompat.getScaledVerticalScrollFactor(
ViewConfiguration.get(context), context,
)
val newScale = (scale + axisValue).coerceIn(MIN_SCALE, MAX_SCALE)
scaleChild(newScale, event.x, event.y)
return true
}
}
}
return super.onGenericMotionEvent(event)
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (!isZoomEnable) {
return super.onKeyDown(keyCode, event)
}
return when (keyCode) {
KeyEvent.KEYCODE_ZOOM_IN,
KeyEvent.KEYCODE_NUMPAD_ADD,
KeyEvent.KEYCODE_PLUS -> {
onZoomIn()
true
}
KeyEvent.KEYCODE_ZOOM_OUT,
KeyEvent.KEYCODE_NUMPAD_SUBTRACT,
KeyEvent.KEYCODE_MINUS -> {
onZoomOut()
true
}
KeyEvent.KEYCODE_ESCAPE -> {
smoothScaleTo(1f)
true
}
else -> super.onKeyDown(keyCode, event)
}
}
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
return if (isZoomEnable) {
keyCode == KeyEvent.KEYCODE_NUMPAD_ADD
|| keyCode == KeyEvent.KEYCODE_PLUS
|| keyCode == KeyEvent.KEYCODE_NUMPAD_SUBTRACT
|| keyCode == KeyEvent.KEYCODE_MINUS
|| keyCode == KeyEvent.KEYCODE_ZOOM_IN
|| keyCode == KeyEvent.KEYCODE_ZOOM_OUT
|| keyCode == KeyEvent.KEYCODE_ESCAPE
|| super.onKeyUp(keyCode, event)
} else {
super.onKeyUp(keyCode, event)
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
halfWidth = w / 2f
halfHeight = h / 2f
}
override fun onZoomIn() {
smoothScaleTo(scale * 1.1f)
}
override fun onZoomOut() {
smoothScaleTo(scale * 0.9f)
} }
private fun invalidateTarget() { private fun invalidateTarget() {
@@ -154,14 +233,33 @@ class WebtoonScalingFrame @JvmOverloads constructor(
return true return true
} }
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = true override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
animator?.cancel()
animator = null
return true
}
override fun onScaleEnd(p0: ScaleGestureDetector) = Unit override fun onScaleEnd(p0: ScaleGestureDetector) = Unit
private fun smoothScaleTo(target: Float) {
val newScale = target.coerceIn(MIN_SCALE, MAX_SCALE)
animator?.cancel()
animator = ValueAnimator.ofFloat(scale, newScale).apply {
setDuration(context.getAnimationDuration(android.R.integer.config_shortAnimTime))
interpolator = DecelerateInterpolator()
addUpdateListener { scaleChild(it.animatedValue as Float, halfWidth, halfHeight) }
start()
}
}
private inner class GestureListener : GestureDetector.SimpleOnGestureListener(), Runnable { private inner class GestureListener : GestureDetector.SimpleOnGestureListener(), Runnable {
override fun onScroll(e1: MotionEvent?, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean { override fun onScroll(
e1: MotionEvent?,
e2: MotionEvent,
distanceX: Float,
distanceY: Float,
): Boolean {
if (scale <= 1f) return false if (scale <= 1f) return false
transformMatrix.postTranslate(-distanceX, -distanceY) transformMatrix.postTranslate(-distanceX, -distanceY)
invalidateTarget() invalidateTarget()
@@ -181,7 +279,12 @@ class WebtoonScalingFrame @JvmOverloads constructor(
return true return true
} }
override fun onFling(e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float): Boolean { override fun onFling(
e1: MotionEvent?,
e2: MotionEvent,
velocityX: Float,
velocityY: Float,
): Boolean {
if (scale <= 1) return false if (scale <= 1) return false
overScroller.fling( overScroller.fling(
@@ -200,7 +303,10 @@ class WebtoonScalingFrame @JvmOverloads constructor(
override fun run() { override fun run() {
if (overScroller.computeScrollOffset()) { if (overScroller.computeScrollOffset()) {
transformMatrix.postTranslate(overScroller.currX - transX, overScroller.currY - transY) transformMatrix.postTranslate(
overScroller.currX - transX,
overScroller.currY - transY,
)
invalidateTarget() invalidateTarget()
postOnAnimation(this) postOnAnimation(this)
} }

View File

@@ -152,13 +152,6 @@ class PagesThumbnailsSheet :
override fun onScrolledToEnd(recyclerView: RecyclerView) { override fun onScrolledToEnd(recyclerView: RecyclerView) {
viewModel.loadNextChapter() viewModel.loadNextChapter()
} }
override fun onPostScrolled(recyclerView: RecyclerView, firstVisibleItemPosition: Int, visibleItemCount: Int) {
super.onPostScrolled(recyclerView, firstVisibleItemPosition, visibleItemCount)
if (firstVisibleItemPosition > offsetTop) {
viewModel.allowLoadAbove()
}
}
} }
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() { private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {

View File

@@ -16,7 +16,6 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.firstNotNull import org.koitharu.kotatsu.core.util.ext.firstNotNull
import org.koitharu.kotatsu.core.util.ext.firstNotNullOrNull
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.details.domain.DoubleMangaLoadUseCase import org.koitharu.kotatsu.details.domain.DoubleMangaLoadUseCase
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
@@ -29,10 +28,11 @@ class PagesThumbnailsViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
mangaRepositoryFactory: MangaRepository.Factory, mangaRepositoryFactory: MangaRepository.Factory,
private val chaptersLoader: ChaptersLoader, private val chaptersLoader: ChaptersLoader,
private val doubleMangaLoadUseCase: DoubleMangaLoadUseCase, doubleMangaLoadUseCase: DoubleMangaLoadUseCase,
) : BaseViewModel() { ) : BaseViewModel() {
private val currentPageIndex: Int = savedStateHandle[PagesThumbnailsSheet.ARG_CURRENT_PAGE] ?: -1 private val currentPageIndex: Int =
savedStateHandle[PagesThumbnailsSheet.ARG_CURRENT_PAGE] ?: -1
private val initialChapterId: Long = savedStateHandle[PagesThumbnailsSheet.ARG_CHAPTER_ID] ?: 0L private val initialChapterId: Long = savedStateHandle[PagesThumbnailsSheet.ARG_CHAPTER_ID] ?: 0L
val manga = savedStateHandle.require<ParcelableManga>(PagesThumbnailsSheet.ARG_MANGA).manga val manga = savedStateHandle.require<ParcelableManga>(PagesThumbnailsSheet.ARG_MANGA).manga
@@ -46,7 +46,6 @@ class PagesThumbnailsViewModel @Inject constructor(
private var loadingJob: Job? = null private var loadingJob: Job? = null
private var loadingPrevJob: Job? = null private var loadingPrevJob: Job? = null
private var loadingNextJob: Job? = null private var loadingNextJob: Job? = null
private var isLoadAboveAllowed = false
val thumbnails = MutableStateFlow<List<ListModel>>(emptyList()) val thumbnails = MutableStateFlow<List<ListModel>>(emptyList())
val branch = MutableStateFlow<String?>(null) val branch = MutableStateFlow<String?>(null)
@@ -60,17 +59,8 @@ class PagesThumbnailsViewModel @Inject constructor(
} }
} }
fun allowLoadAbove() {
if (!isLoadAboveAllowed) {
loadingJob = launchLoadingJob(Dispatchers.Default) {
isLoadAboveAllowed = true
updateList()
}
}
}
fun loadPrevChapter() { fun loadPrevChapter() {
if (!isLoadAboveAllowed || loadingJob?.isActive == true || loadingPrevJob?.isActive == true) { if (loadingJob?.isActive == true || loadingPrevJob?.isActive == true) {
return return
} }
loadingPrevJob = loadPrevNextChapter(isNext = false) loadingPrevJob = loadPrevNextChapter(isNext = false)
@@ -91,7 +81,6 @@ class PagesThumbnailsViewModel @Inject constructor(
private suspend fun updateList() { private suspend fun updateList() {
val snapshot = chaptersLoader.snapshot() val snapshot = chaptersLoader.snapshot()
val mangaChapters = mangaDetails.firstNotNullOrNull()?.chapters.orEmpty()
val pages = buildList(snapshot.size + chaptersLoader.size + 2) { val pages = buildList(snapshot.size + chaptersLoader.size + 2) {
var previousChapterId = 0L var previousChapterId = 0L
for (page in snapshot) { for (page in snapshot) {

View File

@@ -8,6 +8,7 @@ import android.view.View
import androidx.appcompat.view.ActionMode import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -118,6 +119,13 @@ class RemoteListFragment : MangaListFragment(), FilterOwner {
override fun onMenuItemActionExpand(item: MenuItem): Boolean { override fun onMenuItemActionExpand(item: MenuItem): Boolean {
(activity as? AppBarOwner)?.appBar?.setExpanded(false, true) (activity as? AppBarOwner)?.appBar?.setExpanded(false, true)
(item.actionView as? SearchView)?.run {
imeOptions = if (viewModel.isIncognitoModeEnabled) {
imeOptions or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING
} else {
imeOptions and EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING.inv()
}
}
return true return true
} }

View File

@@ -10,8 +10,10 @@ import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTag import org.koitharu.kotatsu.core.db.entity.toMangaTag
import org.koitharu.kotatsu.core.db.entity.toMangaTagsList
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.ContentType
@@ -19,6 +21,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.parsers.util.levenshteinDistance
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider import org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider
import javax.inject.Inject import javax.inject.Inject
@@ -93,8 +96,17 @@ class MangaSearchRepository @Inject constructor(
query.isNotEmpty() -> db.tagsDao.findTags("%$query%", limit) query.isNotEmpty() -> db.tagsDao.findTags("%$query%", limit)
source != null -> db.tagsDao.findPopularTags(source.name, limit) source != null -> db.tagsDao.findPopularTags(source.name, limit)
else -> db.tagsDao.findPopularTags(limit) else -> db.tagsDao.findPopularTags(limit)
}.map { }.toMangaTagsList()
it.toMangaTag() }
suspend fun getTagsSuggestion(tags: Set<MangaTag>): List<MangaTag> {
val ids = tags.mapToSet { it.toEntity().id }
return if (ids.size == 1) {
db.tagsDao.findRelatedTags(ids.first())
} else {
db.tagsDao.findRelatedTags(ids)
}.mapNotNull { x ->
if (x.id in ids) null else x.toMangaTag()
} }
} }

View File

@@ -17,14 +17,15 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
@@ -64,7 +65,7 @@ class MangaListActivity :
if (viewBinding.containerFilterHeader != null) { if (viewBinding.containerFilterHeader != null) {
viewBinding.appbar.addOnOffsetChangedListener(this) viewBinding.appbar.addOnOffsetChangedListener(this)
} }
val source = intent.getSerializableExtraCompat(EXTRA_SOURCE) ?: tags?.firstOrNull()?.source val source = intent.getStringExtra(EXTRA_SOURCE)?.let(::MangaSource) ?: tags?.firstOrNull()?.source
if (source == null) { if (source == null) {
finishAfterTransition() finishAfterTransition()
return return
@@ -186,11 +187,14 @@ class MangaListActivity :
private const val EXTRA_TAGS = "tags" private const val EXTRA_TAGS = "tags"
private const val EXTRA_SOURCE = "source" private const val EXTRA_SOURCE = "source"
const val ACTION_MANGA_EXPLORE = "${BuildConfig.APPLICATION_ID}.action.EXPLORE_MANGA"
fun newIntent(context: Context, tags: Set<MangaTag>) = Intent(context, MangaListActivity::class.java) fun newIntent(context: Context, tags: Set<MangaTag>) = Intent(context, MangaListActivity::class.java)
.setAction(ACTION_MANGA_EXPLORE)
.putExtra(EXTRA_TAGS, ParcelableMangaTags(tags)) .putExtra(EXTRA_TAGS, ParcelableMangaTags(tags))
fun newIntent(context: Context, source: MangaSource) = Intent(context, MangaListActivity::class.java) fun newIntent(context: Context, source: MangaSource) = Intent(context, MangaListActivity::class.java)
.putExtra(EXTRA_SOURCE, source) .setAction(ACTION_MANGA_EXPLORE)
.putExtra(EXTRA_SOURCE, source.name)
} }
} }

View File

@@ -59,7 +59,6 @@ class MultiSearchActivity :
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(ActivitySearchMultiBinding.inflate(layoutInflater)) setContentView(ActivitySearchMultiBinding.inflate(layoutInflater))
window.statusBarColor = ContextCompat.getColor(this, R.color.dim_statusbar)
title = viewModel.query title = viewModel.query
val itemCLickListener = OnListItemClickListener<MultiSearchListModel> { item, view -> val itemCLickListener = OnListItemClickListener<MultiSearchListModel> { item, view ->

View File

@@ -7,6 +7,7 @@ import android.text.Spannable
import android.text.SpannableString import android.text.SpannableString
import android.text.style.TextAppearanceSpan import android.text.style.TextAppearanceSpan
import android.util.AttributeSet import android.util.AttributeSet
import android.view.InputDevice
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MotionEvent import android.view.MotionEvent
import android.view.SoundEffectConstants import android.view.SoundEffectConstants
@@ -59,7 +60,11 @@ class SearchEditText @JvmOverloads constructor(
} }
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode == KeyEvent.KEYCODE_ENTER && event.hasNoModifiers() && query.isNotEmpty()) { if (event.isFromSource(InputDevice.SOURCE_KEYBOARD)
&& (keyCode == KeyEvent.KEYCODE_ENTER || keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER)
&& event.hasNoModifiers()
&& query.isNotEmpty()
) {
cancelLongPress() cancelLongPress()
searchSuggestionListener?.onQueryClick(query, submit = true) searchSuggestionListener?.onQueryClick(query, submit = true)
clearFocus() clearFocus()

View File

@@ -29,6 +29,7 @@ import org.koitharu.kotatsu.databinding.ActivitySettingsBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.settings.about.AboutSettingsFragment import org.koitharu.kotatsu.settings.about.AboutSettingsFragment
import org.koitharu.kotatsu.settings.about.AppUpdateDialog
import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment
import org.koitharu.kotatsu.settings.sources.SourcesManageFragment import org.koitharu.kotatsu.settings.sources.SourcesManageFragment
import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment
@@ -41,6 +42,8 @@ class SettingsActivity :
AppBarOwner, AppBarOwner,
FragmentManager.OnBackStackChangedListener { FragmentManager.OnBackStackChangedListener {
val appUpdateDialog = AppUpdateDialog(this)
override val appBar: AppBarLayout override val appBar: AppBarLayout
get() = viewBinding.appbar get() = viewBinding.appbar

View File

@@ -30,7 +30,7 @@ class SyncSettingsFragment : BasePreferenceFragment(R.string.sync_settings), Fra
override fun onPreferenceTreeClick(preference: Preference): Boolean { override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) { return when (preference.key) {
SyncSettings.KEY_HOST -> { SyncSettings.KEY_HOST -> {
SyncHostDialogFragment.show(childFragmentManager) SyncHostDialogFragment.show(childFragmentManager, null)
true true
} }

View File

@@ -20,6 +20,7 @@ import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ShareHelper
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.settings.SettingsActivity
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -76,7 +77,7 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) {
Snackbar.make(listView, R.string.no_update_available, Snackbar.LENGTH_SHORT).show() Snackbar.make(listView, R.string.no_update_available, Snackbar.LENGTH_SHORT).show()
return return
} }
AppUpdateDialog(context ?: return).show(version) (activity as SettingsActivity).appUpdateDialog.show(version)
} }
private fun openLink(url: String, title: CharSequence?) { private fun openLink(url: String, title: CharSequence?) {

View File

@@ -1,7 +1,14 @@
package org.koitharu.kotatsu.settings.about package org.koitharu.kotatsu.settings.about
import android.Manifest
import android.app.DownloadManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Environment
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -11,31 +18,71 @@ import org.koitharu.kotatsu.core.github.AppVersion
import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.FileSize
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
class AppUpdateDialog(private val context: Context) { class AppUpdateDialog(private val activity: AppCompatActivity) {
private lateinit var latestVersion: AppVersion
private val permissionRequest = activity.registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) {
if (it) {
downloadUpdateImpl()
} else {
openInBrowser()
}
}
fun show(version: AppVersion) { fun show(version: AppVersion) {
latestVersion = version
val message = buildSpannedString { val message = buildSpannedString {
append(context.getString(R.string.new_version_s, version.name)) append(activity.getString(R.string.new_version_s, version.name))
appendLine() appendLine()
append(context.getString(R.string.size_s, FileSize.BYTES.format(context, version.apkSize))) append(activity.getString(R.string.size_s, FileSize.BYTES.format(activity, version.apkSize)))
appendLine() appendLine()
appendLine() appendLine()
append(Markwon.create(context).toMarkdown(version.description)) append(Markwon.create(activity).toMarkdown(version.description))
} }
MaterialAlertDialogBuilder( MaterialAlertDialogBuilder(
context, activity,
materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered, materialR.style.ThemeOverlay_Material3_MaterialAlertDialog_Centered,
) )
.setTitle(R.string.app_update_available) .setTitle(R.string.app_update_available)
.setMessage(message) .setMessage(message)
.setIcon(R.drawable.ic_app_update) .setIcon(R.drawable.ic_app_update)
.setPositiveButton(R.string.download) { _, _ -> .setNeutralButton(R.string.open_in_browser) { _, _ ->
val intent = Intent(Intent.ACTION_VIEW, version.apkUrl.toUri()) val intent = Intent(Intent.ACTION_VIEW, version.url.toUri())
context.startActivity(Intent.createChooser(intent, context.getString(R.string.open_in_browser))) activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.open_in_browser)))
} }.setPositiveButton(R.string.update) { _, _ ->
.setNegativeButton(R.string.close, null) downloadUpdate()
}.setNegativeButton(android.R.string.cancel, null)
.setCancelable(false) .setCancelable(false)
.create() .create()
.show() .show()
} }
private fun downloadUpdate() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
permissionRequest.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
} else {
downloadUpdateImpl()
}
}
private fun downloadUpdateImpl() {
val version = latestVersion
val url = version.apkUrl.toUri()
val dm = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
val request = DownloadManager.Request(url)
.setTitle("${activity.getString(R.string.app_name)} v${version.name}")
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, url.lastPathSegment)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setMimeType("application/vnd.android.package-archive")
dm.enqueue(request)
Toast.makeText(activity, R.string.download_started, Toast.LENGTH_SHORT).show()
}
private fun openInBrowser() {
val intent = Intent(Intent.ACTION_VIEW, latestVersion.url.toUri())
activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.open_in_browser)))
}
} }

View File

@@ -0,0 +1,38 @@
package org.koitharu.kotatsu.settings.about
import android.app.DownloadManager
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
class UpdateDownloadReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
if (downloadId == 0L) {
return
}
val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
@Suppress("DEPRECATION")
val installIntent = Intent(Intent.ACTION_INSTALL_PACKAGE)
installIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
installIntent.setDataAndType(
dm.getUriForDownloadedFile(downloadId),
dm.getMimeTypeForDownloadedFile(downloadId),
)
installIntent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
try {
context.startActivity(installIntent)
} catch (e: ActivityNotFoundException) {
e.printStackTraceDebug()
}
}
}
}
}

View File

@@ -114,7 +114,7 @@ class NavConfigFragment : BaseFragment<FragmentSettingsSourcesBinding>(), Recycl
recyclerView: RecyclerView, recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder, viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder,
): Boolean = true ): Boolean = target.itemViewType == ListItemType.NAV_ITEM.ordinal
override fun onMoved( override fun onMoved(
recyclerView: RecyclerView, recyclerView: RecyclerView,

View File

@@ -63,9 +63,13 @@ class NavConfigViewModel @Inject constructor(
} }
fun removeItem(item: NavItem) { fun removeItem(item: NavItem) {
items.value = items.value.minus(item).also { val newList = items.value.toMutableList()
commit(it) newList.remove(item)
if (newList.isEmpty()) {
newList.add(NavItem.EXPLORE)
} }
items.value = newList
commit(newList)
} }
private fun commit(value: List<NavItem>) { private fun commit(value: List<NavItem>) {

View File

@@ -59,6 +59,8 @@ class NewSourcesDialogFragment :
override fun onItemLiftClick(item: SourceConfigItem.SourceItem) = Unit override fun onItemLiftClick(item: SourceConfigItem.SourceItem) = Unit
override fun onItemShortcutClick(item: SourceConfigItem.SourceItem) = Unit
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
viewModel.onItemEnabledChanged(item, isEnabled) viewModel.onItemEnabledChanged(item, isEnabled)
} }

View File

@@ -15,7 +15,9 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.ImageLoader import coil.ImageLoader
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner
@@ -24,6 +26,7 @@ import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.getItem import org.koitharu.kotatsu.core.util.ext.getItem
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
@@ -44,6 +47,9 @@ class SourcesManageFragment :
@Inject @Inject
lateinit var settings: AppSettings lateinit var settings: AppSettings
@Inject
lateinit var shortcutManager: AppShortcutManager
private var reorderHelper: ItemTouchHelper? = null private var reorderHelper: ItemTouchHelper? = null
private val viewModel by viewModels<SourcesManageViewModel>() private val viewModel by viewModels<SourcesManageViewModel>()
@@ -103,6 +109,12 @@ class SourcesManageFragment :
viewModel.bringToTop(item.source) viewModel.bringToTop(item.source)
} }
override fun onItemShortcutClick(item: SourceConfigItem.SourceItem) {
viewLifecycleScope.launch {
shortcutManager.requestPinShortcut(item.source)
}
}
override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) { override fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) {
viewModel.setEnabled(item.source, isEnabled) viewModel.setEnabled(item.source, isEnabled)
} }

View File

@@ -8,6 +8,7 @@ import android.text.style.RelativeSizeSpan
import android.text.style.SuperscriptSpan import android.text.style.SuperscriptSpan
import android.view.View import android.view.View
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans import androidx.core.text.inSpans
import androidx.core.view.isGone import androidx.core.view.isGone
@@ -39,7 +40,7 @@ fun sourceConfigHeaderDelegate() =
ItemFilterHeaderBinding.inflate( ItemFilterHeaderBinding.inflate(
layoutInflater, layoutInflater,
parent, parent,
false false,
) )
}, },
) { ) {
@@ -76,7 +77,7 @@ fun sourceConfigItemCheckableDelegate(
ItemSourceConfigCheckableBinding.inflate( ItemSourceConfigCheckableBinding.inflate(
layoutInflater, layoutInflater,
parent, parent,
false false,
) )
}, },
) { ) {
@@ -121,7 +122,7 @@ fun sourceConfigItemDelegate2(
ItemSourceConfigBinding.inflate( ItemSourceConfigBinding.inflate(
layoutInflater, layoutInflater,
parent, parent,
false false,
) )
}, },
) { ) {
@@ -189,8 +190,8 @@ fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans(
ForegroundColorSpan( ForegroundColorSpan(
context.getThemeColor( context.getThemeColor(
com.google.android.material.R.attr.colorError, com.google.android.material.R.attr.colorError,
Color.RED Color.RED,
) ),
), ),
RelativeSizeSpan(0.74f), RelativeSizeSpan(0.74f),
SuperscriptSpan(), SuperscriptSpan(),
@@ -205,10 +206,13 @@ private fun showSourceMenu(
) { ) {
val menu = PopupMenu(anchor.context, anchor) val menu = PopupMenu(anchor.context, anchor)
menu.inflate(R.menu.popup_source_config) menu.inflate(R.menu.popup_source_config)
menu.menu.findItem(R.id.action_shortcut)
?.isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(anchor.context)
menu.setOnMenuItemClickListener { menu.setOnMenuItemClickListener {
when (it.itemId) { when (it.itemId) {
R.id.action_settings -> listener.onItemSettingsClick(item) R.id.action_settings -> listener.onItemSettingsClick(item)
R.id.action_lift -> listener.onItemLiftClick(item) R.id.action_lift -> listener.onItemLiftClick(item)
R.id.action_shortcut -> listener.onItemShortcutClick(item)
} }
true true
} }

View File

@@ -9,6 +9,8 @@ interface SourceConfigListener : OnTipCloseListener<SourceConfigItem.Tip> {
fun onItemLiftClick(item: SourceConfigItem.SourceItem) fun onItemLiftClick(item: SourceConfigItem.SourceItem)
fun onItemShortcutClick(item: SourceConfigItem.SourceItem)
fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean) fun onItemEnabledChanged(item: SourceConfigItem.SourceItem, isEnabled: Boolean)
fun onHeaderClick(header: SourceConfigItem.LocaleGroup) fun onHeaderClick(header: SourceConfigItem.LocaleGroup)

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.suggestions.ui package org.koitharu.kotatsu.suggestions.ui
import android.annotation.SuppressLint
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
@@ -10,6 +11,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
import androidx.core.text.HtmlCompat import androidx.core.text.HtmlCompat
import androidx.core.text.bold
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import androidx.core.text.parseAsHtml import androidx.core.text.parseAsHtml
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
@@ -48,6 +50,7 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.almostEquals import org.koitharu.kotatsu.core.util.ext.almostEquals
import org.koitharu.kotatsu.core.util.ext.asArrayList import org.koitharu.kotatsu.core.util.ext.asArrayList
import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.flatten import org.koitharu.kotatsu.core.util.ext.flatten
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.core.util.ext.sanitize
@@ -163,7 +166,7 @@ class SuggestionsWorker @AssistedInject constructor(
.sortedBy { it.relevance } .sortedBy { it.relevance }
.take(MAX_RESULTS) .take(MAX_RESULTS)
suggestionRepository.replace(suggestions) suggestionRepository.replace(suggestions)
if (appSettings.isSuggestionsNotificationAvailable) { if (appSettings.isSuggestionsNotificationAvailable && applicationContext.checkNotificationPermission()) {
for (i in 0..3) { for (i in 0..3) {
try { try {
val manga = suggestions[Random.nextInt(0, suggestions.size / 3)] val manga = suggestions[Random.nextInt(0, suggestions.size / 3)]
@@ -221,10 +224,8 @@ class SuggestionsWorker @AssistedInject constructor(
e.printStackTraceDebug() e.printStackTraceDebug()
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
@SuppressLint("MissingPermission")
private suspend fun showNotification(manga: Manga) { private suspend fun showNotification(manga: Manga) {
if (!notificationManager.areNotificationsEnabled()) {
return
}
val channel = NotificationChannelCompat.Builder(MANGA_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) val channel = NotificationChannelCompat.Builder(MANGA_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setName(applicationContext.getString(R.string.suggestions)) .setName(applicationContext.getString(R.string.suggestions))
.setDescription(applicationContext.getString(R.string.suggestions_summary)) .setDescription(applicationContext.getString(R.string.suggestions_summary))
@@ -255,17 +256,19 @@ class SuggestionsWorker @AssistedInject constructor(
style.bigText( style.bigText(
buildSpannedString { buildSpannedString {
append(tagsText) append(tagsText)
appendLine()
append(description)
val chaptersCount = manga.chapters?.size ?: 0 val chaptersCount = manga.chapters?.size ?: 0
appendLine() appendLine()
append( bold {
applicationContext.resources.getQuantityString( append(
R.plurals.chapters, applicationContext.resources.getQuantityString(
chaptersCount, R.plurals.chapters,
chaptersCount, chaptersCount,
), chaptersCount,
) ),
)
}
appendLine()
append(description)
}, },
) )
style.setBigContentTitle(title) style.setBigContentTitle(title)

View File

@@ -7,6 +7,7 @@ import android.content.ContentProviderResult
import android.content.Context import android.content.Context
import android.content.OperationApplicationException import android.content.OperationApplicationException
import android.content.SyncResult import android.content.SyncResult
import android.content.SyncStats
import android.database.Cursor import android.database.Cursor
import android.net.Uri import android.net.Uri
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
@@ -67,7 +68,7 @@ class SyncHelper @AssistedInject constructor(
get() = TimeUnit.DAYS.toMillis(4) get() = TimeUnit.DAYS.toMillis(4)
@WorkerThread @WorkerThread
fun syncFavourites(syncResult: SyncResult) { fun syncFavourites(stats: SyncStats) {
val data = JSONObject() val data = JSONObject()
data.put(TABLE_FAVOURITE_CATEGORIES, getFavouriteCategories()) data.put(TABLE_FAVOURITE_CATEGORIES, getFavouriteCategories())
data.put(TABLE_FAVOURITES, getFavourites()) data.put(TABLE_FAVOURITES, getFavourites())
@@ -81,17 +82,18 @@ class SyncHelper @AssistedInject constructor(
val timestamp = response.getLong(FIELD_TIMESTAMP) val timestamp = response.getLong(FIELD_TIMESTAMP)
val categoriesResult = val categoriesResult =
upsertFavouriteCategories(response.getJSONArray(TABLE_FAVOURITE_CATEGORIES), timestamp) upsertFavouriteCategories(response.getJSONArray(TABLE_FAVOURITE_CATEGORIES), timestamp)
syncResult.stats.numDeletes += categoriesResult.first().count?.toLong() ?: 0L stats.numDeletes += categoriesResult.first().count?.toLong() ?: 0L
syncResult.stats.numInserts += categoriesResult.drop(1).sumOf { it.count?.toLong() ?: 0L } stats.numInserts += categoriesResult.drop(1).sumOf { it.count?.toLong() ?: 0L }
val favouritesResult = upsertFavourites(response.getJSONArray(TABLE_FAVOURITES), timestamp) val favouritesResult = upsertFavourites(response.getJSONArray(TABLE_FAVOURITES), timestamp)
syncResult.stats.numDeletes += favouritesResult.first().count?.toLong() ?: 0L stats.numDeletes += favouritesResult.first().count?.toLong() ?: 0L
syncResult.stats.numInserts += favouritesResult.drop(1).sumOf { it.count?.toLong() ?: 0L } stats.numInserts += favouritesResult.drop(1).sumOf { it.count?.toLong() ?: 0L }
stats.numEntries += stats.numInserts + stats.numDeletes
} }
gcFavourites() gcFavourites()
} }
@WorkerThread @WorkerThread
fun syncHistory(syncResult: SyncResult) { fun syncHistory(stats: SyncStats) {
val data = JSONObject() val data = JSONObject()
data.put(TABLE_HISTORY, getHistory()) data.put(TABLE_HISTORY, getHistory())
data.put(FIELD_TIMESTAMP, System.currentTimeMillis()) data.put(FIELD_TIMESTAMP, System.currentTimeMillis())
@@ -105,8 +107,9 @@ class SyncHelper @AssistedInject constructor(
json = response.getJSONArray(TABLE_HISTORY), json = response.getJSONArray(TABLE_HISTORY),
timestamp = response.getLong(FIELD_TIMESTAMP), timestamp = response.getLong(FIELD_TIMESTAMP),
) )
syncResult.stats.numDeletes += result.first().count?.toLong() ?: 0L stats.numDeletes += result.first().count?.toLong() ?: 0L
syncResult.stats.numInserts += result.drop(1).sumOf { it.count?.toLong() ?: 0L } stats.numInserts += result.drop(1).sumOf { it.count?.toLong() ?: 0L }
stats.numEntries += stats.numInserts + stats.numDeletes
} }
gcHistory() gcHistory()
} }

View File

@@ -104,7 +104,7 @@ class SyncAuthActivity : BaseActivity<ActivitySyncAuthBinding>(), View.OnClickLi
} }
R.id.button_settings -> { R.id.button_settings -> {
SyncHostDialogFragment.show(supportFragmentManager) SyncHostDialogFragment.show(supportFragmentManager, viewModel.host.value)
} }
} }
} }

View File

@@ -10,7 +10,6 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.sync.data.SyncAuthApi import org.koitharu.kotatsu.sync.data.SyncAuthApi
import org.koitharu.kotatsu.sync.domain.SyncAuthResult import org.koitharu.kotatsu.sync.domain.SyncAuthResult
import javax.inject.Inject import javax.inject.Inject
@@ -23,9 +22,7 @@ class SyncAuthViewModel @Inject constructor(
val onAccountAlreadyExists = MutableEventFlow<Unit>() val onAccountAlreadyExists = MutableEventFlow<Unit>()
val onTokenObtained = MutableEventFlow<SyncAuthResult>() val onTokenObtained = MutableEventFlow<SyncAuthResult>()
val host = MutableStateFlow("") val host = MutableStateFlow(context.getString(R.string.sync_host_default))
private val defaultHost = context.getString(R.string.sync_host_default)
init { init {
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
@@ -38,7 +35,7 @@ class SyncAuthViewModel @Inject constructor(
} }
fun obtainToken(email: String, password: String) { fun obtainToken(email: String, password: String) {
val hostValue = host.value.ifNullOrEmpty { defaultHost } val hostValue = host.value
launchLoadingJob(Dispatchers.Default) { launchLoadingJob(Dispatchers.Default) {
val token = api.authenticate(hostValue, email, password) val token = api.authenticate(hostValue, email, password)
val result = SyncAuthResult(host.value, email, password, token) val result = SyncAuthResult(host.value, email, password, token)

View File

@@ -13,6 +13,8 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.PreferenceDialogAutocompletetextviewBinding import org.koitharu.kotatsu.databinding.PreferenceDialogAutocompletetextviewBinding
import org.koitharu.kotatsu.settings.utils.validation.DomainValidator import org.koitharu.kotatsu.settings.utils.validation.DomainValidator
import org.koitharu.kotatsu.sync.data.SyncSettings import org.koitharu.kotatsu.sync.data.SyncSettings
@@ -50,7 +52,7 @@ class SyncHostDialogFragment : AlertDialogFragment<PreferenceDialogAutocompletet
binding.message.setText(R.string.sync_host_description) binding.message.setText(R.string.sync_host_description)
val entries = binding.root.resources.getStringArray(R.array.sync_host_list) val entries = binding.root.resources.getStringArray(R.array.sync_host_list)
val editText = binding.edit val editText = binding.edit
editText.setText(syncSettings.host) editText.setText(arguments?.getString(KEY_HOST).ifNullOrEmpty { syncSettings.host })
editText.threshold = 0 editText.threshold = 0
editText.setAdapter(ArrayAdapter(binding.root.context, android.R.layout.simple_spinner_dropdown_item, entries)) editText.setAdapter(ArrayAdapter(binding.root.context, android.R.layout.simple_spinner_dropdown_item, entries))
binding.dropdown.setOnClickListener { binding.dropdown.setOnClickListener {
@@ -76,6 +78,8 @@ class SyncHostDialogFragment : AlertDialogFragment<PreferenceDialogAutocompletet
const val REQUEST_KEY = "sync_host" const val REQUEST_KEY = "sync_host"
const val KEY_HOST = "host" const val KEY_HOST = "host"
fun show(fm: FragmentManager) = SyncHostDialogFragment().show(fm, TAG) fun show(fm: FragmentManager, host: String?) = SyncHostDialogFragment().withArgs(1) {
putString(KEY_HOST, host)
}.show(fm, TAG)
} }
} }

View File

@@ -28,7 +28,7 @@ class FavouritesSyncAdapter(context: Context) : AbstractThreadedSyncAdapter(cont
val entryPoint = EntryPointAccessors.fromApplication(context, SyncAdapterEntryPoint::class.java) val entryPoint = EntryPointAccessors.fromApplication(context, SyncAdapterEntryPoint::class.java)
val syncHelper = entryPoint.syncHelperFactory.create(account, provider) val syncHelper = entryPoint.syncHelperFactory.create(account, provider)
runCatchingCancellable { runCatchingCancellable {
syncHelper.syncFavourites(syncResult) syncHelper.syncFavourites(syncResult.stats)
SyncController.setLastSync(context, account, authority, System.currentTimeMillis()) SyncController.setLastSync(context, account, authority, System.currentTimeMillis())
}.onFailure { e -> }.onFailure { e ->
syncResult.onError(e) syncResult.onError(e)

View File

@@ -28,7 +28,7 @@ class HistorySyncAdapter(context: Context) : AbstractThreadedSyncAdapter(context
val entryPoint = EntryPointAccessors.fromApplication(context, SyncAdapterEntryPoint::class.java) val entryPoint = EntryPointAccessors.fromApplication(context, SyncAdapterEntryPoint::class.java)
val syncHelper = entryPoint.syncHelperFactory.create(account, provider) val syncHelper = entryPoint.syncHelperFactory.create(account, provider)
runCatchingCancellable { runCatchingCancellable {
syncHelper.syncHistory(syncResult) syncHelper.syncHistory(syncResult.stats)
SyncController.setLastSync(context, account, authority, System.currentTimeMillis()) SyncController.setLastSync(context, account, authority, System.currentTimeMillis())
}.onFailure { e -> }.onFailure { e ->
syncResult.onError(e) syncResult.onError(e)

View File

@@ -1,9 +1,9 @@
package org.koitharu.kotatsu.tracker.data package org.koitharu.kotatsu.tracker.data
import java.util.*
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import java.util.Date
fun TrackLogWithManga.toTrackingLogItem(counters: MutableMap<Long, Int>): TrackingLogItem { fun TrackLogWithManga.toTrackingLogItem(counters: MutableMap<Long, Int>): TrackingLogItem {
val chaptersList = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() } val chaptersList = trackLog.chapters.split('\n').filterNot { x -> x.isEmpty() }
@@ -16,7 +16,7 @@ fun TrackLogWithManga.toTrackingLogItem(counters: MutableMap<Long, Int>): Tracki
) )
} }
private fun MutableMap<Long, Int>.decrement(key: Long, count: Int): Boolean { private fun MutableMap<Long, Int>.decrement(key: Long, count: Int): Boolean = synchronized(this) {
val counter = get(key) val counter = get(key)
if (counter == null || counter <= 0) { if (counter == null || counter <= 0) {
return false return false

View File

@@ -13,10 +13,11 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.core.util.ext.mapItems
import org.koitharu.kotatsu.favourites.data.toFavouriteCategory import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.tracker.data.TrackEntity import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity
@@ -25,16 +26,19 @@ import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates import org.koitharu.kotatsu.tracker.domain.model.MangaUpdates
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import java.util.Date import java.util.Date
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider
private const val NO_ID = 0L private const val NO_ID = 0L
@Reusable @Reusable
class TrackingRepository @Inject constructor( class TrackingRepository @Inject constructor(
private val db: MangaDatabase, private val db: MangaDatabase,
private val localMangaRepositoryProvider: Provider<LocalMangaRepository>,
) { ) {
private var isGcCalled = false private var isGcCalled = AtomicBoolean(false)
suspend fun getNewChaptersCount(mangaId: Long): Int { suspend fun getNewChaptersCount(mangaId: Long): Int {
return db.tracksDao.findNewChapters(mangaId) ?: 0 return db.tracksDao.findNewChapters(mangaId) ?: 0
@@ -65,12 +69,17 @@ class TrackingRepository @Inject constructor(
val idSet = HashSet<Long>() val idSet = HashSet<Long>()
val result = ArrayList<MangaTracking>(mangaList.size) val result = ArrayList<MangaTracking>(mangaList.size)
for (item in mangaList) { for (item in mangaList) {
if (item.source == MangaSource.LOCAL || !idSet.add(item.id)) { val manga = if (item.isLocal) {
localMangaRepositoryProvider.get().getRemoteManga(item) ?: continue
} else {
item
}
if (!idSet.add(manga.id)) {
continue continue
} }
val track = tracks[item.id]?.lastOrNull() val track = tracks[manga.id]?.lastOrNull()
result += MangaTracking( result += MangaTracking(
manga = item, manga = manga,
lastChapterId = track?.lastChapterId ?: NO_ID, lastChapterId = track?.lastChapterId ?: NO_ID,
lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date), lastCheck = track?.lastCheck?.takeUnless { it == 0L }?.let(::Date),
) )
@@ -227,9 +236,8 @@ class TrackingRepository @Inject constructor(
} }
private suspend fun gcIfNotCalled() { private suspend fun gcIfNotCalled() {
if (!isGcCalled) { if (isGcCalled.compareAndSet(false, true)) {
gc() gc()
isGcCalled = true
} }
} }

View File

@@ -6,7 +6,6 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.isBold
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.databinding.ItemFeedBinding import org.koitharu.kotatsu.databinding.ItemFeedBinding
@@ -26,8 +25,9 @@ fun feedItemAD(
} }
bind { bind {
binding.textViewTitle.isBold = item.isNew val alpha = if (item.isNew) 1f else 0.5f
binding.textViewSummary.isBold = item.isNew binding.textViewTitle.alpha = alpha
binding.textViewSummary.alpha = alpha
binding.imageViewCover.newImageRequest(lifecycleOwner, item.imageUrl)?.run { binding.imageViewCover.newImageRequest(lifecycleOwner, item.imageUrl)?.run {
placeholder(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
fallback(R.drawable.ic_placeholder) fallback(R.drawable.ic_placeholder)

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android"> <selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.6" android:color="?attr/colorSurface" /> <item android:alpha="0.6" android:color="?android:colorBackground" />
</selector> </selector>

View File

@@ -0,0 +1,11 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M15.5,14L20.5,19L19,20.5L14,15.5V14.71L13.73,14.43C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.43,13.73L14.71,14H15.5M9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14M12,10H10V12H9V10H7V9H9V7H10V9H12V10Z" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M15.5,14H14.71L14.43,13.73C15.41,12.59 16,11.11 16,9.5A6.5,6.5 0 0,0 9.5,3A6.5,6.5 0 0,0 3,9.5A6.5,6.5 0 0,0 9.5,16C11.11,16 12.59,15.41 13.73,14.43L14,14.71V15.5L19,20.5L20.5,19L15.5,14M9.5,14C7,14 5,12 5,9.5C5,7 7,5 9.5,5C12,5 14,7 14,9.5C14,12 12,14 9.5,14M7,9H12V10H7V9Z" />
</vector>

View File

@@ -26,6 +26,17 @@
<solid android:color="@color/selector_overlay" /> <solid android:color="@color/selector_overlay" />
</shape> </shape>
</item> </item>
<item
android:bottom="2dp"
android:left="2dp"
android:right="2dp"
android:state_hovered="true"
android:top="2dp">
<shape android:shape="rectangle">
<corners android:radius="@dimen/list_selector_corner" />
<solid android:color="@color/selector_overlay" />
</shape>
</item>
<item <item
android:bottom="2dp" android:bottom="2dp"
android:left="2dp" android:left="2dp"

View File

@@ -64,6 +64,7 @@
android:focusable="true" android:focusable="true"
android:focusableInTouchMode="true" android:focusableInTouchMode="true"
app:contentInsetStartWithNavigation="0dp" app:contentInsetStartWithNavigation="0dp"
app:navigationContentDescription="@string/search"
app:navigationIcon="?attr/actionModeWebSearchDrawable"> app:navigationIcon="?attr/actionModeWebSearchDrawable">
<org.koitharu.kotatsu.search.ui.widget.SearchEditText <org.koitharu.kotatsu.search.ui.widget.SearchEditText

View File

@@ -48,6 +48,7 @@
android:focusable="true" android:focusable="true"
android:focusableInTouchMode="true" android:focusableInTouchMode="true"
app:contentInsetStartWithNavigation="0dp" app:contentInsetStartWithNavigation="0dp"
app:navigationContentDescription="@string/search"
app:navigationIcon="?attr/actionModeWebSearchDrawable"> app:navigationIcon="?attr/actionModeWebSearchDrawable">
<org.koitharu.kotatsu.search.ui.widget.SearchEditText <org.koitharu.kotatsu.search.ui.widget.SearchEditText

View File

@@ -4,8 +4,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
android:keepScreenOn="true">
<androidx.fragment.app.FragmentContainerView <androidx.fragment.app.FragmentContainerView
android:id="@+id/container" android:id="@+id/container"

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:defaultFocusHighlightEnabled="false"
android:orientation="horizontal"
app:layoutManager="org.koitharu.kotatsu.reader.ui.pager.doublepage.DoublePageLayoutManager" />

View File

@@ -3,4 +3,5 @@
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/pager" android:id="@+id/pager"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" /> android:layout_height="match_parent"
android:defaultFocusHighlightEnabled="false" />

View File

@@ -2,13 +2,29 @@
<org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonScalingFrame <org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonScalingFrame
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/frame" android:id="@+id/frame"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent"
android:defaultFocusHighlightEnabled="false"
android:focusable="true">
<org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonRecyclerView <org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonRecyclerView
android:id="@+id/recyclerView" android:id="@+id/recyclerView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:defaultFocusHighlightEnabled="false"
android:orientation="vertical" android:orientation="vertical"
app:layoutManager="org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonLayoutManager" /> app:layoutManager="org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonLayoutManager" />
<org.koitharu.kotatsu.core.ui.widgets.ZoomControl
android:id="@+id/zoomControl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible" />
</org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonScalingFrame> </org.koitharu.kotatsu.reader.ui.pager.webtoon.WebtoonScalingFrame>

View File

@@ -30,7 +30,7 @@
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="1" android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyLarge" android:textAppearance="?attr/textAppearanceTitleSmall"
app:layout_constraintBottom_toTopOf="@+id/textView_summary" app:layout_constraintBottom_toTopOf="@+id/textView_summary"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView_cover" app:layout_constraintStart_toEndOf="@+id/imageView_cover"
@@ -45,7 +45,7 @@
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="1" android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyMedium" android:textAppearance="?attr/textAppearanceBodySmall"
app:layout_constraintBottom_toBottomOf="@+id/imageView_cover" app:layout_constraintBottom_toBottomOf="@+id/imageView_cover"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView_cover" app:layout_constraintStart_toEndOf="@+id/imageView_cover"

View File

@@ -31,7 +31,7 @@
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="1" android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyLarge" android:textAppearance="?attr/textAppearanceTitleSmall"
app:layout_constraintBottom_toTopOf="@+id/textView_subtitle" app:layout_constraintBottom_toTopOf="@+id/textView_subtitle"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView_cover" app:layout_constraintStart_toEndOf="@+id/imageView_cover"
@@ -46,7 +46,7 @@
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="1" android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyMedium" android:textAppearance="?attr/textAppearanceBodySmall"
app:layout_constraintBottom_toBottomOf="@+id/imageView_cover" app:layout_constraintBottom_toBottomOf="@+id/imageView_cover"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView_cover" app:layout_constraintStart_toEndOf="@+id/imageView_cover"

View File

@@ -40,8 +40,7 @@
android:layout_marginEnd="12dp" android:layout_marginEnd="12dp"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="2" android:maxLines="2"
android:textAppearance="?attr/textAppearanceTitleLarge" android:textAppearance="?attr/textAppearanceTitleMedium"
android:textSize="20sp"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView_cover" app:layout_constraintStart_toEndOf="@+id/imageView_cover"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
@@ -57,7 +56,7 @@
android:ellipsize="end" android:ellipsize="end"
android:gravity="center_vertical" android:gravity="center_vertical"
android:maxLines="2" android:maxLines="2"
android:textAppearance="?attr/textAppearanceSubtitle1" android:textAppearance="?attr/textAppearanceBodySmall"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView_cover" app:layout_constraintStart_toEndOf="@+id/imageView_cover"
app:layout_constraintTop_toBottomOf="@+id/textView_title" app:layout_constraintTop_toBottomOf="@+id/textView_title"

View File

@@ -10,6 +10,8 @@
android:id="@+id/ssiv" android:id="@+id/ssiv"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:defaultFocusHighlightEnabled="false"
android:focusable="true"
app:restoreStrategy="deferred" /> app:restoreStrategy="deferred" />
<TextView <TextView
@@ -25,4 +27,14 @@
<include layout="@layout/layout_page_info" /> <include layout="@layout/layout_page_info" />
<org.koitharu.kotatsu.core.ui.widgets.ZoomControl
android:id="@+id/zoomControl"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible" />
</FrameLayout> </FrameLayout>

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