Compare commits
226 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4c52654a7 | ||
|
|
44b71460ee | ||
|
|
265fbc9f63 | ||
|
|
7c4b254f08 | ||
|
|
1bf01ca240 | ||
|
|
54ff63dbc7 | ||
|
|
61ddee0bba | ||
|
|
8174d236f6 | ||
|
|
b27d5607ac | ||
|
|
905f565766 | ||
|
|
b33c93290b | ||
|
|
5abb07fda2 | ||
|
|
b57069c55f | ||
|
|
5b1a4d3ff5 | ||
|
|
2b26f944d0 | ||
|
|
a15197f69d | ||
|
|
41f64b2e36 | ||
|
|
bec032c7dc | ||
|
|
0ffefddb86 | ||
|
|
09b154c997 | ||
|
|
d9f3b4f76e | ||
|
|
8ebb3ef804 | ||
|
|
b03682a81f | ||
|
|
5dd54be06c | ||
|
|
98c0b60207 | ||
|
|
10a0009532 | ||
|
|
5e203f0b27 | ||
|
|
46fc48cfd7 | ||
|
|
e8a17708d2 | ||
|
|
061eaa2a56 | ||
|
|
bc6e29b562 | ||
|
|
d8c1dcef29 | ||
|
|
ca281afba1 | ||
|
|
cde07a60d7 | ||
|
|
e31af0f43f | ||
|
|
15dd0f38e7 | ||
|
|
d93647e889 | ||
|
|
509d9a2fba | ||
|
|
879d05f1a6 | ||
|
|
ecf6bbfb66 | ||
|
|
bc42fda786 | ||
|
|
d3590372f3 | ||
|
|
88f55997fa | ||
|
|
0a1bc6716b | ||
|
|
559e546462 | ||
|
|
6c5775a2ed | ||
|
|
4858adbbe7 | ||
|
|
cae07b2798 | ||
|
|
b14603c384 | ||
|
|
2f21d0f0f8 | ||
|
|
7e182cb0ad | ||
|
|
f79d2cb733 | ||
|
|
ce296900c5 | ||
|
|
0156ae86eb | ||
|
|
efd82b6d96 | ||
|
|
b4371d2cd2 | ||
|
|
676c94d759 | ||
|
|
b4c8fb7f9b | ||
|
|
5f79d37506 | ||
|
|
2e074573c0 | ||
|
|
82281312fb | ||
|
|
ed6a906459 | ||
|
|
00b01f298d | ||
|
|
aa99ea1245 | ||
|
|
732c614aad | ||
|
|
afe16859d4 | ||
|
|
c95f2aa9a1 | ||
|
|
630cece4f5 | ||
|
|
f0101bc183 | ||
|
|
7c2829226d | ||
|
|
83bd390c2a | ||
|
|
b090652007 | ||
|
|
569edd91c9 | ||
|
|
2e81684652 | ||
|
|
2573d150f9 | ||
|
|
24fe83aa5c | ||
|
|
bbc39becc3 | ||
|
|
65077c1fba | ||
|
|
bec0ce2c96 | ||
|
|
256f0a31bc | ||
|
|
b8e48d8b8a | ||
|
|
8313d6966f | ||
|
|
7e581a5ed7 | ||
|
|
16027e3295 | ||
|
|
e4e14214d9 | ||
|
|
e40a39ca28 | ||
|
|
82e711d619 | ||
|
|
8c2bff78f7 | ||
|
|
4f2c38d4ee | ||
|
|
3c54fe4217 | ||
|
|
750bf11fdc | ||
|
|
b2c5ec5082 | ||
|
|
f97d4d452f | ||
|
|
640fe272c8 | ||
|
|
f730e80bb7 | ||
|
|
d975e92991 | ||
|
|
0d29190bd1 | ||
|
|
76c07b1567 | ||
|
|
55c82a6f5c | ||
|
|
f81d298315 | ||
|
|
fd17e1ea20 | ||
|
|
a1dc401eee | ||
|
|
b5ee465cde | ||
|
|
956b04e974 | ||
|
|
f65c213e2d | ||
|
|
813ce2e195 | ||
|
|
0eb320ec76 | ||
|
|
b17aa6c031 | ||
|
|
97b5102e6c | ||
|
|
fe408c0832 | ||
|
|
4f3d1a9814 | ||
|
|
65abfc3a49 | ||
|
|
ba88ca8234 | ||
|
|
63470db6f5 | ||
|
|
1e39ae48ec | ||
|
|
6fcc45d554 | ||
|
|
094b0f694c | ||
|
|
5d1a2fcf77 | ||
|
|
876675445d | ||
|
|
51362e6cce | ||
|
|
f7a70680bd | ||
|
|
8e82db441c | ||
|
|
f2626c668d | ||
|
|
4694215ccc | ||
|
|
096f5b15dc | ||
|
|
101d357eff | ||
|
|
11cd5609bb | ||
|
|
fda59996aa | ||
|
|
20461112d2 | ||
|
|
f98bb87d6e | ||
|
|
c451952a1e | ||
|
|
f8cbc9692f | ||
|
|
9f3113363b | ||
|
|
dba36838d4 | ||
|
|
f6de1b02d7 | ||
|
|
d6b8e2fd9e | ||
|
|
5227240478 | ||
|
|
8f65ea6535 | ||
|
|
7d7a6eadd2 | ||
|
|
40f1ad3181 | ||
|
|
a28c9447d7 | ||
|
|
a84cf97982 | ||
|
|
3a8eb58fd1 | ||
|
|
5d75e9af4a | ||
|
|
d4684e7462 | ||
|
|
c0a2f0b533 | ||
|
|
40867dd2b6 | ||
|
|
c3294e6459 | ||
|
|
5139feb51a | ||
|
|
6b1240fccb | ||
|
|
e00a5b7505 | ||
|
|
2c07d2c8e1 | ||
|
|
45c3c05f01 | ||
|
|
e97a745713 | ||
|
|
2dc4de0a3c | ||
|
|
3cf2c58058 | ||
|
|
1e19f32fc5 | ||
|
|
99e4359523 | ||
|
|
04868488cc | ||
|
|
2b3b406b84 | ||
|
|
7ab3c75232 | ||
|
|
61f7755465 | ||
|
|
9389015ab9 | ||
|
|
bc56a94aa6 | ||
|
|
7cfcaec6dd | ||
|
|
39c7ae31cd | ||
|
|
9349eccc0c | ||
|
|
8204934359 | ||
|
|
b5497c571e | ||
|
|
35a2ac4b04 | ||
|
|
b4d52f1367 | ||
|
|
81d4a3cf68 | ||
|
|
c2e30b3009 | ||
|
|
0c823f1056 | ||
|
|
44adbde536 | ||
|
|
ae0b405ba5 | ||
|
|
325a8be484 | ||
|
|
f39ccb6223 | ||
|
|
6cb6c891dd | ||
|
|
8cc04b0f7a | ||
|
|
258dbf3dc3 | ||
|
|
e7af4e8450 | ||
|
|
0c25c61858 | ||
|
|
abc3e45907 | ||
|
|
bd98d8eded | ||
|
|
2e81f41073 | ||
|
|
5cccebc416 | ||
|
|
c668ffd555 | ||
|
|
a0f77b715f | ||
|
|
2831843a25 | ||
|
|
86c1aa11b0 | ||
|
|
d71514ec7a | ||
|
|
92ed320f57 | ||
|
|
2de1fe8b77 | ||
|
|
cebc3cd9e8 | ||
|
|
6c0e2e2b90 | ||
|
|
b4bd923ce8 | ||
|
|
813561fd3b | ||
|
|
4107336132 | ||
|
|
30d9d87c17 | ||
|
|
c4b5be657d | ||
|
|
8a763b2b9f | ||
|
|
c783378022 | ||
|
|
c4355f16e8 | ||
|
|
522dfc2418 | ||
|
|
06d03e3ddd | ||
|
|
9dc8c7959d | ||
|
|
db219020ca | ||
|
|
c04edcb76c | ||
|
|
936fc2e4ae | ||
|
|
cbed866665 | ||
|
|
ac568b6361 | ||
|
|
84157f988d | ||
|
|
6f6339f0f8 | ||
|
|
a7019b9096 | ||
|
|
867e3f10ca | ||
|
|
fb2cf04d75 | ||
|
|
3ed44ba0d6 | ||
|
|
b78104a0f1 | ||
|
|
e4ee93f77c | ||
|
|
c6e8da5f23 | ||
|
|
376de7cce3 | ||
|
|
bec2195971 | ||
|
|
722ac4ecc7 | ||
|
|
516c1c02a6 | ||
|
|
0cb7e71781 |
3
.idea/gradle.xml
generated
3
.idea/gradle.xml
generated
@@ -4,9 +4,8 @@
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="GRADLE" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="jbr-17" />
|
||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
|
||||
53
LICENSE
53
LICENSE
@@ -619,56 +619,3 @@ Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
||||
@@ -16,8 +16,8 @@ android {
|
||||
applicationId 'org.koitharu.kotatsu'
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
versionCode = 622
|
||||
versionName = '6.7'
|
||||
versionCode = 633
|
||||
versionName = '6.8.3'
|
||||
generatedDensities = []
|
||||
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||
ksp {
|
||||
@@ -82,19 +82,19 @@ afterEvaluate {
|
||||
}
|
||||
dependencies {
|
||||
//noinspection GradleDependency
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:014ea5ef49') {
|
||||
implementation('com.github.KotatsuApp:kotatsu-parsers:44ea9fe709') {
|
||||
exclude group: 'org.json', module: 'json'
|
||||
}
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.22'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.23'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0'
|
||||
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.activity:activity-ktx:1.8.2'
|
||||
implementation 'androidx.fragment:fragment-ktx:1.6.2'
|
||||
implementation 'androidx.collection:collection:1.4.0'
|
||||
implementation 'androidx.collection:collection-ktx:1.4.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-service:2.7.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.7.0'
|
||||
@@ -104,8 +104,9 @@ dependencies {
|
||||
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
|
||||
implementation 'androidx.preference:preference-ktx:1.2.1'
|
||||
implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05'
|
||||
implementation 'com.google.android.material:material:1.12.0-alpha03'
|
||||
implementation 'com.google.android.material:material:1.12.0-beta01'
|
||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.7.0'
|
||||
implementation 'androidx.webkit:webkit:1.10.0'
|
||||
|
||||
implementation 'androidx.work:work-runtime:2.9.0'
|
||||
//noinspection GradleDependency
|
||||
@@ -121,18 +122,18 @@ dependencies {
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
|
||||
implementation 'com.squareup.okio:okio:3.8.0'
|
||||
implementation 'com.squareup.okio:okio:3.9.0'
|
||||
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||
|
||||
implementation 'com.google.dagger:hilt-android:2.50'
|
||||
kapt 'com.google.dagger:hilt-compiler:2.50'
|
||||
implementation 'androidx.hilt:hilt-work:1.1.0'
|
||||
kapt 'androidx.hilt:hilt-compiler:1.1.0'
|
||||
implementation 'com.google.dagger:hilt-android:2.51.1'
|
||||
kapt 'com.google.dagger:hilt-compiler:2.51.1'
|
||||
implementation 'androidx.hilt:hilt-work:1.2.0'
|
||||
kapt 'androidx.hilt:hilt-compiler:1.2.0'
|
||||
|
||||
implementation 'io.coil-kt:coil-base:2.5.0'
|
||||
implementation 'io.coil-kt:coil-svg:2.5.0'
|
||||
implementation 'io.coil-kt:coil-base:2.6.0'
|
||||
implementation 'io.coil-kt:coil-svg:2.6.0'
|
||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9'
|
||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||
implementation 'io.noties.markwon:core:4.6.2'
|
||||
@@ -147,19 +148,19 @@ dependencies {
|
||||
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.13'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.json:json:20240205'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
||||
testImplementation 'org.json:json:20240303'
|
||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0'
|
||||
|
||||
androidTestImplementation 'androidx.test:runner:1.5.2'
|
||||
androidTestImplementation 'androidx.test:rules:1.5.0'
|
||||
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
|
||||
androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.5'
|
||||
|
||||
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
||||
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0'
|
||||
|
||||
androidTestImplementation 'androidx.room:room-testing:2.6.1'
|
||||
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.1'
|
||||
|
||||
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.50'
|
||||
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.50'
|
||||
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.51.1'
|
||||
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.51.1'
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ class AppShortcutManagerTest {
|
||||
page = 4,
|
||||
scroll = 2,
|
||||
percent = 0.3f,
|
||||
force = false,
|
||||
)
|
||||
awaitUpdate()
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import dagger.hilt.android.testing.HiltAndroidRule
|
||||
import dagger.hilt.android.testing.HiltAndroidTest
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -61,6 +63,7 @@ class AppBackupAgentTest {
|
||||
page = 3,
|
||||
scroll = 40,
|
||||
percent = 0.2f,
|
||||
force = false,
|
||||
)
|
||||
val history = checkNotNull(historyRepository.getOne(SampleData.manga))
|
||||
|
||||
|
||||
@@ -94,6 +94,34 @@
|
||||
<data android:host="kotatsu.app" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.details.ui.DetailsActivity2"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="${applicationId}.action.VIEW_MANGA" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="http" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="kotatsu.app" />
|
||||
<data android:path="/manga" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="kotatsu" />
|
||||
<data android:host="manga" />
|
||||
<data android:host="kotatsu.app" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.reader.ui.ReaderActivity"
|
||||
android:exported="true">
|
||||
@@ -239,6 +267,12 @@
|
||||
<data android:scheme="kotatsu+kitsu" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.stats.ui.StatsActivity"
|
||||
android:label="@string/reading_stats" />
|
||||
<activity
|
||||
android:name="org.koitharu.kotatsu.alternatives.ui.AlternativesActivity"
|
||||
android:label="@string/alternatives" />
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package org.koitharu.kotatsu.alternatives.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.almostEquals
|
||||
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val MAX_PARALLELISM = 4
|
||||
private const val MATCH_THRESHOLD = 0.2f
|
||||
|
||||
class AlternativesUseCase @Inject constructor(
|
||||
private val sourcesRepository: MangaSourcesRepository,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(manga: Manga): Flow<Manga> {
|
||||
val sources = getSources(manga.source)
|
||||
if (sources.isEmpty()) {
|
||||
return emptyFlow()
|
||||
}
|
||||
val semaphore = Semaphore(MAX_PARALLELISM)
|
||||
return channelFlow {
|
||||
for (source in sources) {
|
||||
val repository = mangaRepositoryFactory.create(source)
|
||||
if (!repository.isSearchSupported) {
|
||||
continue
|
||||
}
|
||||
launch {
|
||||
val list = runCatchingCancellable {
|
||||
semaphore.withPermit {
|
||||
repository.getList(offset = 0, filter = MangaListFilter.Search(manga.title))
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
for (item in list) {
|
||||
if (item.matches(manga)) {
|
||||
send(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.map {
|
||||
runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(it.source).getDetails(it)
|
||||
}.getOrDefault(it)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getSources(ref: MangaSource): List<MangaSource> {
|
||||
val result = ArrayList<MangaSource>(MangaSource.entries.size - 2)
|
||||
result.addAll(sourcesRepository.getEnabledSources())
|
||||
result.sortByDescending { it.priority(ref) }
|
||||
result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) })
|
||||
return result
|
||||
}
|
||||
|
||||
private fun Manga.matches(ref: Manga): Boolean {
|
||||
return matchesTitles(title, ref.title) ||
|
||||
matchesTitles(title, ref.altTitle) ||
|
||||
matchesTitles(altTitle, ref.title) ||
|
||||
matchesTitles(altTitle, ref.altTitle)
|
||||
|
||||
}
|
||||
|
||||
private fun matchesTitles(a: String?, b: String?): Boolean {
|
||||
return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, MATCH_THRESHOLD)
|
||||
}
|
||||
|
||||
private fun MangaSource.priority(ref: MangaSource): Int {
|
||||
var res = 0
|
||||
if (locale == ref.locale) res += 2
|
||||
if (contentType == ref.contentType) res++
|
||||
return res
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package org.koitharu.kotatsu.alternatives.domain
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.model.getPreferredBranch
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
import org.koitharu.kotatsu.history.data.PROGRESS_NONE
|
||||
import org.koitharu.kotatsu.history.data.toMangaHistory
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
|
||||
class MigrateUseCase @Inject constructor(
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val mangaDataRepository: MangaDataRepository,
|
||||
private val database: MangaDatabase,
|
||||
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
||||
) {
|
||||
|
||||
suspend operator fun invoke(oldManga: Manga, newManga: Manga) {
|
||||
val oldDetails = if (oldManga.chapters.isNullOrEmpty()) {
|
||||
runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(oldManga.source).getDetails(oldManga)
|
||||
}.getOrDefault(oldManga)
|
||||
} else {
|
||||
oldManga
|
||||
}
|
||||
val newDetails = if (newManga.chapters.isNullOrEmpty()) {
|
||||
mangaRepositoryFactory.create(newManga.source).getDetails(newManga)
|
||||
} else {
|
||||
newManga
|
||||
}
|
||||
mangaDataRepository.storeManga(newDetails)
|
||||
database.withTransaction {
|
||||
// replace favorites
|
||||
val favoritesDao = database.getFavouritesDao()
|
||||
val oldFavourites = favoritesDao.findAllRaw(oldDetails.id)
|
||||
if (oldFavourites.isNotEmpty()) {
|
||||
favoritesDao.delete(oldManga.id)
|
||||
for (f in oldFavourites) {
|
||||
val e = f.copy(
|
||||
mangaId = newManga.id,
|
||||
)
|
||||
favoritesDao.upsert(e)
|
||||
}
|
||||
}
|
||||
// replace history
|
||||
val historyDao = database.getHistoryDao()
|
||||
val oldHistory = historyDao.find(oldDetails.id)
|
||||
if (oldHistory != null) {
|
||||
val newHistory = makeNewHistory(oldDetails, newDetails, oldHistory)
|
||||
historyDao.delete(oldDetails.id)
|
||||
historyDao.upsert(newHistory)
|
||||
}
|
||||
}
|
||||
progressUpdateUseCase(newManga)
|
||||
}
|
||||
|
||||
private fun makeNewHistory(
|
||||
oldManga: Manga,
|
||||
newManga: Manga,
|
||||
history: HistoryEntity,
|
||||
): HistoryEntity {
|
||||
if (oldManga.chapters.isNullOrEmpty()) { // probably broken manga/source
|
||||
val branch = newManga.getPreferredBranch(null)
|
||||
val chapters = checkNotNull(newManga.getChapters(branch))
|
||||
val currentChapter = if (history.percent in 0f..1f) {
|
||||
chapters[(chapters.lastIndex * history.percent).toInt()]
|
||||
} else {
|
||||
chapters.first()
|
||||
}
|
||||
return HistoryEntity(
|
||||
mangaId = newManga.id,
|
||||
createdAt = history.createdAt,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
chapterId = currentChapter.id,
|
||||
page = history.page,
|
||||
scroll = history.scroll,
|
||||
percent = history.percent,
|
||||
deletedAt = 0,
|
||||
chaptersCount = chapters.size,
|
||||
)
|
||||
}
|
||||
val branch = oldManga.getPreferredBranch(history.toMangaHistory())
|
||||
val oldChapters = checkNotNull(oldManga.getChapters(branch))
|
||||
var index = oldChapters.indexOfFirst { it.id == history.chapterId }
|
||||
if (index < 0) {
|
||||
index = if (history.percent in 0f..1f) {
|
||||
(oldChapters.lastIndex * history.percent).toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
val newChapters = checkNotNull(newManga.chapters).groupBy { it.branch }
|
||||
val newBranch = if (newChapters.containsKey(branch)) {
|
||||
branch
|
||||
} else {
|
||||
newManga.getPreferredBranch(null)
|
||||
}
|
||||
val newChapterId = checkNotNull(newChapters[newBranch]).let {
|
||||
val oldChapter = oldChapters[index]
|
||||
it.findByNumber(oldChapter.volume, oldChapter.number) ?: it.getOrNull(index) ?: it.last()
|
||||
}.id
|
||||
|
||||
return HistoryEntity(
|
||||
mangaId = newManga.id,
|
||||
createdAt = history.createdAt,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
chapterId = newChapterId,
|
||||
page = history.page,
|
||||
scroll = history.scroll,
|
||||
percent = PROGRESS_NONE,
|
||||
deletedAt = 0,
|
||||
chaptersCount = checkNotNull(newChapters[newBranch]).size,
|
||||
)
|
||||
}
|
||||
|
||||
private fun List<MangaChapter>.findByNumber(volume: Int, number: Float): MangaChapter? {
|
||||
return if (number <= 0f) {
|
||||
null
|
||||
} else {
|
||||
firstOrNull { it.volume == volume && it.number == number }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.transform.CircleCropTransformation
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
|
||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.databinding.ItemMangaAlternativeBinding
|
||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import kotlin.math.sign
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
fun alternativeAD(
|
||||
coil: ImageLoader,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
listener: OnListItemClickListener<MangaAlternativeModel>,
|
||||
) = adapterDelegateViewBinding<MangaAlternativeModel, ListModel, ItemMangaAlternativeBinding>(
|
||||
{ inflater, parent -> ItemMangaAlternativeBinding.inflate(inflater, parent, false) },
|
||||
) {
|
||||
|
||||
val colorGreen = ContextCompat.getColor(context, R.color.common_green)
|
||||
val colorRed = ContextCompat.getColor(context, R.color.common_red)
|
||||
val clickListener = AdapterDelegateClickListenerAdapter(this, listener)
|
||||
itemView.setOnClickListener(clickListener)
|
||||
binding.buttonMigrate.setOnClickListener(clickListener)
|
||||
binding.chipSource.setOnClickListener(clickListener)
|
||||
|
||||
bind { payloads ->
|
||||
binding.textViewTitle.text = item.manga.title
|
||||
binding.textViewSubtitle.text = buildSpannedString {
|
||||
if (item.chaptersCount > 0) {
|
||||
append(context.resources.getQuantityString(R.plurals.chapters, item.chaptersCount, item.chaptersCount))
|
||||
} else {
|
||||
append(context.getString(R.string.no_chapters))
|
||||
}
|
||||
when (item.chaptersDiff.sign) {
|
||||
-1 -> inSpans(ForegroundColorSpan(colorRed)) {
|
||||
append(" ▼ ")
|
||||
append(item.chaptersDiff.toString())
|
||||
}
|
||||
|
||||
1 -> inSpans(ForegroundColorSpan(colorGreen)) {
|
||||
append(" ▲ +")
|
||||
append(item.chaptersDiff.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.progressView.setPercent(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads)
|
||||
binding.chipSource.also { chip ->
|
||||
chip.text = item.manga.source.title
|
||||
ImageRequest.Builder(context)
|
||||
.data(item.manga.source.faviconUri())
|
||||
.lifecycle(lifecycleOwner)
|
||||
.crossfade(false)
|
||||
.size(context.resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
|
||||
.target(ChipIconTarget(chip))
|
||||
.placeholder(R.drawable.ic_web)
|
||||
.fallback(R.drawable.ic_web)
|
||||
.error(R.drawable.ic_web)
|
||||
.source(item.manga.source)
|
||||
.transformations(CircleCropTransformation())
|
||||
.allowRgb565(true)
|
||||
.enqueueWith(coil)
|
||||
}
|
||||
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.run {
|
||||
size(CoverSizeResolver(binding.imageViewCover))
|
||||
placeholder(R.drawable.ic_placeholder)
|
||||
fallback(R.drawable.ic_placeholder)
|
||||
error(R.drawable.ic_error_placeholder)
|
||||
transformations(TrimTransformation())
|
||||
allowRgb565(true)
|
||||
tag(item.manga)
|
||||
source(item.manga.source)
|
||||
enqueueWith(coil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.updatePadding
|
||||
import coil.ImageLoader
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.DIALOG_THEME_CENTERED
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AlternativesActivity : BaseActivity<ActivityAlternativesBinding>(),
|
||||
OnListItemClickListener<MangaAlternativeModel> {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private val viewModel by viewModels<AlternativesViewModel>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityAlternativesBinding.inflate(layoutInflater))
|
||||
supportActionBar?.run {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
subtitle = viewModel.manga.title
|
||||
}
|
||||
val listAdapter = BaseListAdapter<ListModel>()
|
||||
.addDelegate(ListItemType.MANGA_LIST_DETAILED, alternativeAD(coil, this, this))
|
||||
.addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, null))
|
||||
.addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||
.addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||
with(viewBinding.recyclerView) {
|
||||
setHasFixedSize(true)
|
||||
addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false))
|
||||
adapter = listAdapter
|
||||
}
|
||||
|
||||
viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null))
|
||||
viewModel.content.observe(this, listAdapter)
|
||||
viewModel.onMigrated.observeEvent(this) {
|
||||
Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show()
|
||||
startActivity(DetailsActivity.newIntent(this, it))
|
||||
finishAfterTransition()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
)
|
||||
viewBinding.recyclerView.updatePadding(
|
||||
bottom = insets.bottom + viewBinding.recyclerView.paddingTop,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onItemClick(item: MangaAlternativeModel, view: View) {
|
||||
when (view.id) {
|
||||
R.id.chip_source -> startActivity(SearchActivity.newIntent(this, item.manga.source, viewModel.manga.title))
|
||||
R.id.button_migrate -> confirmMigration(item.manga)
|
||||
else -> startActivity(DetailsActivity.newIntent(this, item.manga))
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmMigration(target: Manga) {
|
||||
MaterialAlertDialogBuilder(this, DIALOG_THEME_CENTERED)
|
||||
.setIcon(R.drawable.ic_replace)
|
||||
.setTitle(R.string.manga_migration)
|
||||
.setMessage(
|
||||
getString(
|
||||
R.string.migrate_confirmation,
|
||||
viewModel.manga.title,
|
||||
viewModel.manga.source.title,
|
||||
target.title,
|
||||
target.source.title,
|
||||
),
|
||||
)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.migrate) { _, _ ->
|
||||
viewModel.migrate(target)
|
||||
}.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun newIntent(context: Context, manga: Manga) = Intent(context, AlternativesActivity::class.java)
|
||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEmpty
|
||||
import kotlinx.coroutines.flow.runningFold
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase
|
||||
import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase
|
||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.require
|
||||
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AlternativesViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||
private val alternativesUseCase: AlternativesUseCase,
|
||||
private val migrateUseCase: MigrateUseCase,
|
||||
private val extraProvider: ListExtraProvider,
|
||||
) : BaseViewModel() {
|
||||
|
||||
val manga = savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga
|
||||
|
||||
val onMigrated = MutableEventFlow<Manga>()
|
||||
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
|
||||
private var migrationJob: Job? = null
|
||||
|
||||
init {
|
||||
launchJob(Dispatchers.Default) {
|
||||
val ref = runCatchingCancellable {
|
||||
mangaRepositoryFactory.create(manga.source).getDetails(manga)
|
||||
}.getOrDefault(manga)
|
||||
val refCount = ref.chaptersCount()
|
||||
alternativesUseCase(ref)
|
||||
.map {
|
||||
MangaAlternativeModel(
|
||||
manga = it,
|
||||
progress = extraProvider.getProgress(it.id),
|
||||
referenceChapters = refCount,
|
||||
)
|
||||
}.runningFold<MangaAlternativeModel, List<ListModel>>(listOf(LoadingState)) { acc, item ->
|
||||
acc.filterIsInstance<MangaAlternativeModel>() + item + LoadingFooter()
|
||||
}.onEmpty {
|
||||
emit(
|
||||
listOf(
|
||||
EmptyState(
|
||||
icon = R.drawable.ic_empty_common,
|
||||
textPrimary = R.string.nothing_found,
|
||||
textSecondary = R.string.text_search_holder_secondary,
|
||||
actionStringRes = 0,
|
||||
),
|
||||
),
|
||||
)
|
||||
}.collect {
|
||||
content.value = it
|
||||
}
|
||||
content.value = content.value.filterNot { it is LoadingFooter }
|
||||
}
|
||||
}
|
||||
|
||||
fun migrate(target: Manga) {
|
||||
if (migrationJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
migrationJob = launchLoadingJob(Dispatchers.Default) {
|
||||
migrateUseCase(manga, target)
|
||||
onMigrated.call(target)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun mapList(list: List<Manga>, refCount: Int): List<MangaAlternativeModel> {
|
||||
return list.map {
|
||||
MangaAlternativeModel(
|
||||
manga = it,
|
||||
progress = extraProvider.getProgress(it.id),
|
||||
referenceChapters = refCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.koitharu.kotatsu.alternatives.ui
|
||||
|
||||
import org.koitharu.kotatsu.core.model.chaptersCount
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
data class MangaAlternativeModel(
|
||||
val manga: Manga,
|
||||
val progress: Float,
|
||||
private val referenceChapters: Int,
|
||||
) : ListModel {
|
||||
|
||||
val chaptersCount = manga.chaptersCount()
|
||||
|
||||
val chaptersDiff: Int
|
||||
get() = if (referenceChapters == 0 || chaptersCount == 0) 0 else chaptersCount - referenceChapters
|
||||
|
||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||
return other is MangaAlternativeModel && other.manga.id == manga.id
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding
|
||||
@@ -71,7 +72,7 @@ class BookmarksFragment :
|
||||
) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
selectionController = ListSelectionController(
|
||||
activity = requireActivity(),
|
||||
appCompatDelegate = checkNotNull(findAppCompatDelegate()),
|
||||
decoration = BookmarksSelectionDecoration(binding.root.context),
|
||||
registryOwner = this,
|
||||
callback = this,
|
||||
@@ -100,7 +101,7 @@ class BookmarksFragment :
|
||||
}
|
||||
viewModel.onError.observeEvent(
|
||||
viewLifecycleOwner,
|
||||
SnackbarErrorObserver(binding.recyclerView, this)
|
||||
SnackbarErrorObserver(binding.recyclerView, this),
|
||||
)
|
||||
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView))
|
||||
}
|
||||
@@ -206,10 +207,11 @@ class BookmarksFragment :
|
||||
companion object {
|
||||
|
||||
@Deprecated(
|
||||
"", ReplaceWith(
|
||||
"",
|
||||
ReplaceWith(
|
||||
"BookmarksFragment()",
|
||||
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment"
|
||||
)
|
||||
"org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment",
|
||||
),
|
||||
)
|
||||
fun newInstance() = BookmarksFragment()
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Deprecated("")
|
||||
@AndroidEntryPoint
|
||||
class BookmarksSheet :
|
||||
BaseAdaptiveSheet<SheetPagesBinding>(),
|
||||
|
||||
@@ -21,6 +21,7 @@ import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import javax.inject.Inject
|
||||
|
||||
@Deprecated("")
|
||||
@HiltViewModel
|
||||
class BookmarksSheetViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.browser
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -12,31 +11,42 @@ import android.webkit.CookieManager
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@AndroidEntryPoint
|
||||
class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback {
|
||||
|
||||
private lateinit var onBackPressedCallback: WebViewBackPressedCallback
|
||||
|
||||
@Inject
|
||||
lateinit var mangaRepositoryFactory: MangaRepository.Factory
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (!catchingWebViewUnavailability { setContentView(ActivityBrowserBinding.inflate(layoutInflater)) }) {
|
||||
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
||||
return
|
||||
}
|
||||
supportActionBar?.run {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||
}
|
||||
with(viewBinding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
userAgentString = UserAgents.CHROME_MOBILE
|
||||
val userAgent = intent?.getSerializableExtraCompat<MangaSource>(EXTRA_SOURCE)?.let { source ->
|
||||
val repository = mangaRepositoryFactory.create(source) as? RemoteMangaRepository
|
||||
repository?.headers?.get(CommonHeaders.USER_AGENT)
|
||||
}
|
||||
viewBinding.webView.configureForParser(userAgent)
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true)
|
||||
viewBinding.webView.webViewClient = BrowserClient(this)
|
||||
viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar)
|
||||
@@ -57,16 +67,6 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
viewBinding.webView.saveState(outState)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
viewBinding.webView.restoreState(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
super.onCreateOptionsMenu(menu)
|
||||
menuInflater.inflate(R.menu.opt_browser, menu)
|
||||
@@ -81,11 +81,14 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
}
|
||||
|
||||
R.id.action_browser -> {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = Uri.parse(viewBinding.webView.url)
|
||||
try {
|
||||
startActivity(Intent.createChooser(intent, item.title))
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
val url = viewBinding.webView.url?.toUriOrNull()
|
||||
if (url != null) {
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = url
|
||||
try {
|
||||
startActivity(Intent.createChooser(intent, item.title))
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -136,11 +139,13 @@ class BrowserActivity : BaseActivity<ActivityBrowserBinding>(), BrowserCallback
|
||||
companion object {
|
||||
|
||||
private const val EXTRA_TITLE = "title"
|
||||
private const val EXTRA_SOURCE = "source"
|
||||
|
||||
fun newIntent(context: Context, url: String, title: String?): Intent {
|
||||
fun newIntent(context: Context, url: String, source: MangaSource?, title: String?): Intent {
|
||||
return Intent(context, BrowserActivity::class.java)
|
||||
.setData(Uri.parse(url))
|
||||
.putExtra(EXTRA_TITLE, title)
|
||||
.putExtra(EXTRA_SOURCE, source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class CaptchaNotifier(
|
||||
) : EventListener {
|
||||
|
||||
fun notify(exception: CloudFlareProtectedException) {
|
||||
if (!context.checkNotificationPermission()) {
|
||||
if (!context.checkNotificationPermission(CHANNEL_ID)) {
|
||||
return
|
||||
}
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
|
||||
@@ -27,9 +27,8 @@ import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||
import org.koitharu.kotatsu.core.util.ext.catchingWebViewUnavailability
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.databinding.ActivityBrowserBinding
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@@ -41,17 +40,12 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
@Inject
|
||||
lateinit var cookieJar: MutableCookieJar
|
||||
|
||||
private lateinit var cfClient: CloudFlareClient
|
||||
private var onBackPressedCallback: WebViewBackPressedCallback? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (!catchingWebViewUnavailability {
|
||||
setContentView(
|
||||
ActivityBrowserBinding.inflate(
|
||||
layoutInflater,
|
||||
),
|
||||
)
|
||||
}) {
|
||||
if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) {
|
||||
return
|
||||
}
|
||||
supportActionBar?.run {
|
||||
@@ -59,13 +53,9 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
|
||||
}
|
||||
val url = intent?.dataString.orEmpty()
|
||||
with(viewBinding.webView.settings) {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
databaseEnabled = true
|
||||
userAgentString = intent?.getStringExtra(ARG_UA) ?: UserAgents.CHROME_MOBILE
|
||||
}
|
||||
viewBinding.webView.webViewClient = CloudFlareClient(cookieJar, this, url)
|
||||
cfClient = CloudFlareClient(cookieJar, this, url)
|
||||
viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
|
||||
viewBinding.webView.webViewClient = cfClient
|
||||
onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also {
|
||||
onBackPressedDispatcher.addCallback(it)
|
||||
}
|
||||
@@ -91,16 +81,6 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
viewBinding.webView.saveState(outState)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
viewBinding.webView.restoreState(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
|
||||
menuInflater.inflate(R.menu.opt_captcha, menu)
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
@@ -125,15 +105,7 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
}
|
||||
|
||||
R.id.action_retry -> {
|
||||
lifecycleScope.launch {
|
||||
viewBinding.webView.stopLoading()
|
||||
yield()
|
||||
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
|
||||
if (targetUrl != null) {
|
||||
clearCfCookies(targetUrl)
|
||||
viewBinding.webView.loadUrl(targetUrl.toString())
|
||||
}
|
||||
}
|
||||
restartCheck()
|
||||
true
|
||||
}
|
||||
|
||||
@@ -159,6 +131,10 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
viewBinding.progressBar.isInvisible = true
|
||||
}
|
||||
|
||||
override fun onLoopDetected() {
|
||||
restartCheck()
|
||||
}
|
||||
|
||||
override fun onCheckPassed() {
|
||||
pendingResult = RESULT_OK
|
||||
finishAfterTransition()
|
||||
@@ -178,10 +154,23 @@ class CloudFlareActivity : BaseActivity<ActivityBrowserBinding>(), CloudFlareCal
|
||||
subtitle?.toString()?.toHttpUrlOrNull()?.topPrivateDomain() ?: subtitle
|
||||
}
|
||||
|
||||
private fun restartCheck() {
|
||||
lifecycleScope.launch {
|
||||
viewBinding.webView.stopLoading()
|
||||
yield()
|
||||
cfClient.reset()
|
||||
val targetUrl = intent?.dataString?.toHttpUrlOrNull()
|
||||
if (targetUrl != null) {
|
||||
clearCfCookies(targetUrl)
|
||||
viewBinding.webView.loadUrl(targetUrl.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun clearCfCookies(url: HttpUrl) = runInterruptible(Dispatchers.Default) {
|
||||
cookieJar.removeCookies(url) { cookie ->
|
||||
val name = cookie.name
|
||||
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf")
|
||||
name.startsWith("cf_") || name.startsWith("_cf") || name.startsWith("__cf") || name == "csrftoken"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,4 +11,6 @@ interface CloudFlareCallback : BrowserCallback {
|
||||
fun onPageLoaded()
|
||||
|
||||
fun onCheckPassed()
|
||||
|
||||
fun onLoopDetected()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.koitharu.kotatsu.browser.BrowserClient
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
|
||||
private const val CF_CLEARANCE = "cf_clearance"
|
||||
private const val LOOP_COUNTER = 3
|
||||
|
||||
class CloudFlareClient(
|
||||
private val cookieJar: MutableCookieJar,
|
||||
@@ -15,6 +16,7 @@ class CloudFlareClient(
|
||||
) : BrowserClient(callback) {
|
||||
|
||||
private val oldClearance = getClearance()
|
||||
private var counter = 0
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
@@ -31,10 +33,20 @@ class CloudFlareClient(
|
||||
callback.onPageLoaded()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
counter = 0
|
||||
}
|
||||
|
||||
private fun checkClearance() {
|
||||
val clearance = getClearance()
|
||||
if (clearance != null && clearance != oldClearance) {
|
||||
callback.onCheckPassed()
|
||||
} else {
|
||||
counter++
|
||||
if (counter >= LOOP_COUNTER) {
|
||||
reset()
|
||||
callback.onLoopDetected()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.Closeable
|
||||
import org.json.JSONArray
|
||||
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
|
||||
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
|
||||
import java.io.File
|
||||
import java.util.EnumSet
|
||||
import java.util.zip.ZipException
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class BackupZipInput(val file: File) : Closeable {
|
||||
class BackupZipInput private constructor(val file: File) : Closeable {
|
||||
|
||||
private val zipFile = ZipFile(file)
|
||||
|
||||
@@ -41,4 +43,17 @@ class BackupZipInput(val file: File) : Closeable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun from(file: File): BackupZipInput = try {
|
||||
val res = BackupZipInput(file)
|
||||
if (res.zipFile.getEntry("index") == null) {
|
||||
throw BadBackupFormatException(null)
|
||||
}
|
||||
res
|
||||
} catch (e: ZipException) {
|
||||
throw BadBackupFormatException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ class JsonDeserializer(private val json: JSONObject) {
|
||||
page = json.getInt("page"),
|
||||
scroll = json.getDouble("scroll").toFloat(),
|
||||
percent = json.getFloatOrDefault("percent", -1f),
|
||||
chaptersCount = json.getIntOrDefault("chapters", -1),
|
||||
deletedAt = 0L,
|
||||
)
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ class JsonSerializer private constructor(private val json: JSONObject) {
|
||||
put("page", e.page)
|
||||
put("scroll", e.scroll)
|
||||
put("percent", e.percent)
|
||||
put("chapters", e.chaptersCount)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ interface ContentCache {
|
||||
|
||||
fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>)
|
||||
|
||||
fun clear(source: MangaSource)
|
||||
|
||||
data class Key(
|
||||
val source: MangaSource,
|
||||
val url: String,
|
||||
|
||||
@@ -7,10 +7,12 @@ class ExpiringLruCache<T>(
|
||||
val maxSize: Int,
|
||||
private val lifetime: Long,
|
||||
private val timeUnit: TimeUnit,
|
||||
) {
|
||||
) : Iterable<ContentCache.Key> {
|
||||
|
||||
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
|
||||
|
||||
override fun iterator(): Iterator<ContentCache.Key> = cache.snapshot().keys.iterator()
|
||||
|
||||
operator fun get(key: ContentCache.Key): T? {
|
||||
val value = cache[key] ?: return null
|
||||
if (value.isExpired) {
|
||||
@@ -30,4 +32,8 @@ class ExpiringLruCache<T>(
|
||||
fun trimToSize(size: Int) {
|
||||
cache.trimToSize(size)
|
||||
}
|
||||
|
||||
fun remove(key: ContentCache.Key) {
|
||||
cache.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,12 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
|
||||
relatedMangaCache[ContentCache.Key(source, url)] = related
|
||||
}
|
||||
|
||||
override fun clear(source: MangaSource) {
|
||||
clearCache(detailsCache, source)
|
||||
clearCache(pagesCache, source)
|
||||
clearCache(relatedMangaCache, source)
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) = Unit
|
||||
|
||||
override fun onLowMemory() = Unit
|
||||
@@ -67,4 +73,12 @@ class MemoryContentCache(application: Application) : ContentCache, ComponentCall
|
||||
else -> cache.trimToSize(cache.maxSize / 2)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) {
|
||||
cache.forEach { key ->
|
||||
if (key.source == source) {
|
||||
cache.remove(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,4 +19,6 @@ class StubContentCache : ContentCache {
|
||||
override suspend fun getRelatedManga(source: MangaSource, url: String): List<Manga>? = null
|
||||
|
||||
override fun putRelatedManga(source: MangaSource, url: String, related: SafeDeferred<List<Manga>>) = Unit
|
||||
|
||||
override fun clear(source: MangaSource) = Unit
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ import org.koitharu.kotatsu.core.db.migrations.Migration14To15
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration15To16
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration16To17
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration17To18
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration18To19
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration19To20
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||
@@ -48,20 +50,22 @@ import org.koitharu.kotatsu.history.data.HistoryDao
|
||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingDao
|
||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblingEntity
|
||||
import org.koitharu.kotatsu.stats.data.StatsDao
|
||||
import org.koitharu.kotatsu.stats.data.StatsEntity
|
||||
import org.koitharu.kotatsu.suggestions.data.SuggestionDao
|
||||
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TrackEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TracksDao
|
||||
|
||||
const val DATABASE_VERSION = 18
|
||||
const val DATABASE_VERSION = 20
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class,
|
||||
FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class,
|
||||
TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class,
|
||||
ScrobblingEntity::class, MangaSourceEntity::class,
|
||||
ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class,
|
||||
],
|
||||
version = DATABASE_VERSION,
|
||||
)
|
||||
@@ -90,6 +94,8 @@ abstract class MangaDatabase : RoomDatabase() {
|
||||
abstract fun getScrobblingDao(): ScrobblingDao
|
||||
|
||||
abstract fun getSourcesDao(): MangaSourcesDao
|
||||
|
||||
abstract fun getStatsDao(): StatsDao
|
||||
}
|
||||
|
||||
fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
@@ -110,6 +116,8 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
||||
Migration15To16(),
|
||||
Migration16To17(context),
|
||||
Migration17To18(),
|
||||
Migration18To19(),
|
||||
Migration19To20(),
|
||||
)
|
||||
|
||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||
|
||||
@@ -29,6 +29,9 @@ abstract class MangaSourcesDao {
|
||||
@Query("SELECT * FROM sources ORDER BY sort_key")
|
||||
abstract fun observeAll(): Flow<List<MangaSourceEntity>>
|
||||
|
||||
@Query("SELECT enabled FROM sources WHERE source = :source")
|
||||
abstract fun observeIsEnabled(source: String): Flow<Boolean>
|
||||
|
||||
@Query("SELECT IFNULL(MAX(sort_key),0) FROM sources")
|
||||
abstract suspend fun getMaxSortKey(): Int
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package org.koitharu.kotatsu.core.db.dao
|
||||
|
||||
import androidx.room.*
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
|
||||
@@ -24,6 +28,9 @@ interface TrackLogsDao {
|
||||
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
|
||||
suspend fun gc()
|
||||
|
||||
@Query("DELETE FROM track_logs WHERE id IN (SELECT id FROM track_logs ORDER BY created_at DESC LIMIT 0 OFFSET :size)")
|
||||
suspend fun trim(size: Int)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM track_logs")
|
||||
suspend fun count(): Int
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration18To19 : Migration(18, 19) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE history ADD COLUMN `chapters` INTEGER NOT NULL DEFAULT -1")
|
||||
db.execSQL("CREATE TABLE IF NOT EXISTS `stats` (`manga_id` INTEGER NOT NULL, `started_at` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `pages` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `started_at`), FOREIGN KEY(`manga_id`) REFERENCES `history`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.koitharu.kotatsu.core.db.migrations
|
||||
|
||||
import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
class Migration19To20 : Migration(19, 20) {
|
||||
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("CREATE TABLE tracks_bk (manga_id INTEGER NOT NULL, chapters_total INTEGER NOT NULL, last_chapter_id INTEGER NOT NULL, chapters_new INTEGER NOT NULL, last_check INTEGER NOT NULL, last_notified_id INTEGER NOT NULL, PRIMARY KEY(manga_id))")
|
||||
db.execSQL("INSERT INTO tracks_bk SELECT manga_id, chapters_total, last_chapter_id, chapters_new, last_check, last_notified_id FROM tracks")
|
||||
db.execSQL("DROP TABLE tracks")
|
||||
db.execSQL("CREATE TABLE tracks (`manga_id` INTEGER NOT NULL, `last_chapter_id` INTEGER NOT NULL, `chapters_new` INTEGER NOT NULL, `last_check_time` INTEGER NOT NULL, `last_chapter_date` INTEGER NOT NULL, `last_result` INTEGER NOT NULL, PRIMARY KEY(`manga_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )")
|
||||
db.execSQL("INSERT INTO tracks SELECT manga_id, last_chapter_id, chapters_new, last_check AS last_check_time, 0 AS last_chapter_date, 0 AS last_result FROM tracks_bk")
|
||||
db.execSQL("DROP TABLE tracks_bk")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import java.io.IOException
|
||||
|
||||
class BadBackupFormatException(cause: Throwable?) : IOException(cause)
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import okio.IOException
|
||||
|
||||
class NoDataReceivedException(
|
||||
private val url: String,
|
||||
) : IOException("No data has been received from $url")
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.exceptions
|
||||
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
|
||||
class UnsupportedSourceException(
|
||||
message: String?,
|
||||
val manga: Manga?,
|
||||
) : IllegalArgumentException(message)
|
||||
@@ -21,7 +21,7 @@ abstract class ErrorObserver(
|
||||
private val onResolved: Consumer<Boolean>?,
|
||||
) : FlowCollector<Throwable> {
|
||||
|
||||
protected val activity = host.context.findActivity()
|
||||
protected open val activity = host.context.findActivity()
|
||||
|
||||
private val lifecycleScope: LifecycleCoroutineScope
|
||||
get() = checkNotNull(fragment?.viewLifecycleScope ?: (activity as? LifecycleOwner)?.lifecycle?.coroutineScope)
|
||||
@@ -36,7 +36,7 @@ abstract class ErrorObserver(
|
||||
private fun isAlive(): Boolean {
|
||||
return when {
|
||||
fragment != null -> fragment.view != null
|
||||
activity != null -> !activity.isDestroyed
|
||||
activity != null -> activity?.isDestroyed == false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,16 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import okhttp3.Headers
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.core.util.TaggedActivityResult
|
||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
|
||||
import kotlin.coroutines.Continuation
|
||||
@@ -59,6 +62,11 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
false
|
||||
}
|
||||
|
||||
is UnsupportedSourceException -> {
|
||||
e.manga?.let { openAlternatives(it) }
|
||||
false
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
@@ -74,7 +82,12 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
|
||||
private fun openInBrowser(url: String) {
|
||||
val context = activity ?: fragment?.activity ?: return
|
||||
context.startActivity(BrowserActivity.newIntent(context, url, null))
|
||||
context.startActivity(BrowserActivity.newIntent(context, url, null, null))
|
||||
}
|
||||
|
||||
private fun openAlternatives(manga: Manga) {
|
||||
val context = activity ?: fragment?.activity ?: return
|
||||
context.startActivity(AlternativesActivity.newIntent(context, manga))
|
||||
}
|
||||
|
||||
private fun getFragmentManager() = checkNotNull(fragment?.childFragmentManager ?: activity?.supportFragmentManager)
|
||||
@@ -86,6 +99,7 @@ class ExceptionResolver : ActivityResultCallback<TaggedActivityResult> {
|
||||
is CloudFlareProtectedException -> R.string.captcha_solve
|
||||
is AuthRequiredException -> R.string.sign_in
|
||||
is NotFoundException -> if (e.url.isNotEmpty()) R.string.open_in_browser else 0
|
||||
is UnsupportedSourceException -> if (e.manga != null) R.string.alternatives else 0
|
||||
else -> 0
|
||||
}
|
||||
|
||||
|
||||
@@ -131,3 +131,19 @@ fun MangaChapter.formatNumber(): String? {
|
||||
}
|
||||
return chaptersNumberFormat.format(number.toDouble())
|
||||
}
|
||||
|
||||
fun Manga.chaptersCount(): Int {
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return 0
|
||||
}
|
||||
val counters = MutableObjectIntMap<String?>()
|
||||
var max = 0
|
||||
chapters?.forEach { x ->
|
||||
val c = counters.getOrDefault(x.branch, 0) + 1
|
||||
counters[x.branch] = c
|
||||
if (max < c) {
|
||||
max = c
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ import okhttp3.Interceptor
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.parser.MangaLoaderContextImpl
|
||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import org.koitharu.kotatsu.parsers.util.mergeWith
|
||||
import java.net.IDN
|
||||
import javax.inject.Inject
|
||||
@@ -20,6 +20,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class CommonHeadersInterceptor @Inject constructor(
|
||||
private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>,
|
||||
private val mangaLoaderContextLazy: Lazy<MangaLoaderContextImpl>,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
@@ -38,7 +39,7 @@ class CommonHeadersInterceptor @Inject constructor(
|
||||
headersBuilder.mergeWith(it, replaceExisting = false)
|
||||
}
|
||||
if (headersBuilder[CommonHeaders.USER_AGENT] == null) {
|
||||
headersBuilder[CommonHeaders.USER_AGENT] = UserAgents.CHROME_MOBILE
|
||||
headersBuilder[CommonHeaders.USER_AGENT] = mangaLoaderContextLazy.get().getDefaultUserAgent()
|
||||
}
|
||||
if (headersBuilder[CommonHeaders.REFERER] == null && repository != null) {
|
||||
val idn = IDN.toASCII(repository.domain)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.parser
|
||||
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.MangaParser
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
@@ -18,24 +19,20 @@ import java.util.EnumSet
|
||||
class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaSource.DUMMY) {
|
||||
|
||||
override val configKeyDomain: ConfigKey.Domain
|
||||
get() = ConfigKey.Domain("")
|
||||
get() = ConfigKey.Domain("localhost")
|
||||
|
||||
override val availableSortOrders: Set<SortOrder>
|
||||
get() = EnumSet.allOf(SortOrder::class.java)
|
||||
|
||||
override suspend fun getDetails(manga: Manga): Manga {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
override suspend fun getDetails(manga: Manga): Manga = stub(manga)
|
||||
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
override suspend fun getList(offset: Int, filter: MangaListFilter?): List<Manga> = stub(null)
|
||||
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = stub(null)
|
||||
|
||||
override suspend fun getAvailableTags(): Set<MangaTag> {
|
||||
TODO("Not yet implemented")
|
||||
override suspend fun getAvailableTags(): Set<MangaTag> = stub(null)
|
||||
|
||||
private fun stub(manga: Manga?): Nothing {
|
||||
throw UnsupportedSourceException("Usage of Dummy parser", manga)
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,25 @@ import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import android.webkit.WebView
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koitharu.kotatsu.core.network.MangaHttpClient
|
||||
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
|
||||
import org.koitharu.kotatsu.core.prefs.SourceSettings
|
||||
import org.koitharu.kotatsu.core.util.ext.configureForParser
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
|
||||
import org.koitharu.kotatsu.core.util.ext.toList
|
||||
import org.koitharu.kotatsu.parsers.MangaLoaderContext
|
||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.network.UserAgents
|
||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
@@ -32,12 +39,15 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
|
||||
private var webViewCached: WeakReference<WebView>? = null
|
||||
|
||||
private val userAgentLazy = SuspendLazy {
|
||||
withContext(Dispatchers.Main) {
|
||||
obtainWebView().settings.userAgentString
|
||||
}.sanitizeHeaderValue()
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override suspend fun evaluateJs(script: String): String? = withContext(Dispatchers.Main) {
|
||||
val webView = webViewCached?.get() ?: WebView(androidContext).also {
|
||||
it.settings.javaScriptEnabled = true
|
||||
webViewCached = WeakReference(it)
|
||||
}
|
||||
val webView = obtainWebView()
|
||||
suspendCoroutine { cont ->
|
||||
webView.evaluateJavascript(script) { result ->
|
||||
cont.resume(result?.takeUnless { it == "null" })
|
||||
@@ -45,6 +55,14 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDefaultUserAgent(): String = runCatching {
|
||||
runBlocking {
|
||||
userAgentLazy.get()
|
||||
}
|
||||
}.onFailure { e ->
|
||||
e.printStackTraceDebug()
|
||||
}.getOrDefault(UserAgents.FIREFOX_MOBILE)
|
||||
|
||||
override fun getConfig(source: MangaSource): MangaSourceConfig {
|
||||
return SourceSettings(androidContext, source)
|
||||
}
|
||||
@@ -60,4 +78,12 @@ class MangaLoaderContextImpl @Inject constructor(
|
||||
override fun getPreferredLocales(): List<Locale> {
|
||||
return LocaleListCompat.getAdjustedDefault().toList()
|
||||
}
|
||||
|
||||
@MainThread
|
||||
private fun obtainWebView(): WebView {
|
||||
return webViewCached?.get() ?: WebView(androidContext).also {
|
||||
it.configureForParser(null)
|
||||
webViewCached = WeakReference(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,11 @@ class RemoteMangaRepository(
|
||||
return getConfig().isSlowdownEnabled
|
||||
}
|
||||
|
||||
private fun getConfig() = parser.config as SourceSettings
|
||||
fun invalidateCache() {
|
||||
cache.clear(source)
|
||||
}
|
||||
|
||||
fun getConfig() = parser.config as SourceSettings
|
||||
|
||||
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
|
||||
var dispatcher = currentCoroutineContext()[CoroutineDispatcher.Key]
|
||||
@@ -227,6 +231,5 @@ class RemoteMangaRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Result<*>.isValidResult() = exceptionOrNull() !is ParseException
|
||||
&& (getOrNull() as? Collection<*>)?.isEmpty() != true
|
||||
private fun Result<*>.isValidResult() = isSuccess && (getOrNull() as? Collection<*>)?.isEmpty() != true
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.collection.ArraySet
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import androidx.preference.PreferenceManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.json.JSONArray
|
||||
@@ -70,10 +71,17 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val isNavLabelsVisible: Boolean
|
||||
get() = prefs.getBoolean(KEY_NAV_LABELS, true)
|
||||
|
||||
var gridSize: Int
|
||||
get() = prefs.getInt(KEY_GRID_SIZE, 100)
|
||||
set(value) = prefs.edit { putInt(KEY_GRID_SIZE, value) }
|
||||
|
||||
var gridSizePages: Int
|
||||
get() = prefs.getInt(KEY_GRID_SIZE_PAGES, 100)
|
||||
set(value) = prefs.edit { putInt(KEY_GRID_SIZE_PAGES, value) }
|
||||
|
||||
var historyListMode: ListMode
|
||||
get() = prefs.getEnumValue(KEY_LIST_MODE_HISTORY, listMode)
|
||||
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_HISTORY, value) }
|
||||
@@ -111,6 +119,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isReaderZoomButtonsEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_ZOOM_BUTTONS, false)
|
||||
|
||||
val isReaderControlAlwaysLTR: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_CONTROL_LTR, false)
|
||||
|
||||
val isReaderFullscreenEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_READER_FULLSCREEN, true)
|
||||
|
||||
@@ -171,10 +182,14 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
get() = prefs.getBoolean(KEY_INCOGNITO_MODE, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_INCOGNITO_MODE, value) }
|
||||
|
||||
var chaptersReverse: Boolean
|
||||
var isChaptersReverse: Boolean
|
||||
get() = prefs.getBoolean(KEY_REVERSE_CHAPTERS, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_REVERSE_CHAPTERS, value) }
|
||||
|
||||
var isChaptersGridView: Boolean
|
||||
get() = prefs.getBoolean(KEY_GRID_VIEW_CHAPTERS, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_GRID_VIEW_CHAPTERS, value) }
|
||||
|
||||
val zoomMode: ZoomMode
|
||||
get() = prefs.getEnumValue(KEY_ZOOM_MODE, ZoomMode.FIT_CENTER)
|
||||
|
||||
@@ -184,11 +199,13 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
var appPassword: String?
|
||||
get() = prefs.getString(KEY_APP_PASSWORD, null)
|
||||
set(value) = prefs.edit {
|
||||
if (value != null) putString(KEY_APP_PASSWORD, value) else remove(
|
||||
KEY_APP_PASSWORD,
|
||||
)
|
||||
if (value != null) putString(KEY_APP_PASSWORD, value) else remove(KEY_APP_PASSWORD)
|
||||
}
|
||||
|
||||
var isAppPasswordNumeric: Boolean
|
||||
get() = prefs.getBoolean(KEY_APP_PASSWORD_NUMERIC, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_APP_PASSWORD_NUMERIC, value) }
|
||||
|
||||
val isLoggingEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_LOGGING_ENABLED, false)
|
||||
|
||||
@@ -208,8 +225,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isUnstableUpdatesAllowed: Boolean
|
||||
get() = prefs.getBoolean(KEY_UPDATES_UNSTABLE, false)
|
||||
|
||||
val isPagesTabEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_PAGES_TAB, true)
|
||||
|
||||
val defaultDetailsTab: Int
|
||||
get() = prefs.getString(KEY_DETAILS_TAB, null)?.toIntOrNull()?.coerceIn(0, 1) ?: 0
|
||||
get() = if (isPagesTabEnabled) {
|
||||
prefs.getString(KEY_DETAILS_TAB, null)?.toIntOrNull()?.coerceIn(0, 1) ?: 0
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
val isContentPrefetchEnabled: Boolean
|
||||
get() {
|
||||
@@ -270,6 +294,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isDownloadsWiFiOnly: Boolean
|
||||
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
|
||||
|
||||
val preferredDownloadFormat: DownloadFormat
|
||||
get() = prefs.getEnumValue(KEY_DOWNLOADS_FORMAT, DownloadFormat.AUTOMATIC)
|
||||
|
||||
var isSuggestionsEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
|
||||
set(value) = prefs.edit { putBoolean(KEY_SUGGESTIONS, value) }
|
||||
@@ -406,6 +433,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
val isReadingTimeEstimationEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_READING_TIME, true)
|
||||
|
||||
val isPagesSavingAskEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_PAGES_SAVE_ASK, true)
|
||||
|
||||
val isStatsEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_STATS_ENABLED, false)
|
||||
|
||||
val isAutoLocalChaptersCleanupEnabled: Boolean
|
||||
get() = prefs.getBoolean(KEY_CHAPTERS_CLEAR_AUTO, false)
|
||||
|
||||
fun isTipEnabled(tip: String): Boolean {
|
||||
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
|
||||
}
|
||||
@@ -418,6 +454,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
prefs.edit { putStringSet(KEY_TIPS_CLOSED, closedTips + tip) }
|
||||
}
|
||||
|
||||
fun getPagesSaveDir(context: Context): DocumentFile? =
|
||||
prefs.getString(KEY_PAGES_SAVE_DIR, null)?.toUriOrNull()?.let {
|
||||
DocumentFile.fromTreeUri(context, it)?.takeIf { it.canWrite() }
|
||||
}
|
||||
|
||||
fun setPagesSaveDir(uri: Uri?) {
|
||||
prefs.edit { putString(KEY_PAGES_SAVE_DIR, uri?.toString()) }
|
||||
}
|
||||
|
||||
fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) {
|
||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
@@ -480,14 +525,18 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear"
|
||||
const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear"
|
||||
const val KEY_COOKIES_CLEAR = "cookies_clear"
|
||||
const val KEY_CHAPTERS_CLEAR = "chapters_clear"
|
||||
const val KEY_CHAPTERS_CLEAR_AUTO = "chapters_clear_auto"
|
||||
const val KEY_THUMBS_CACHE_CLEAR = "thumbs_cache_clear"
|
||||
const val KEY_SEARCH_HISTORY_CLEAR = "search_history_clear"
|
||||
const val KEY_UPDATES_FEED_CLEAR = "updates_feed_clear"
|
||||
const val KEY_GRID_SIZE = "grid_size"
|
||||
const val KEY_GRID_SIZE_PAGES = "grid_size_pages"
|
||||
const val KEY_REMOTE_SOURCES = "remote_sources"
|
||||
const val KEY_LOCAL_STORAGE = "local_storage"
|
||||
const val KEY_READER_DOUBLE_PAGES = "reader_double_pages"
|
||||
const val KEY_READER_ZOOM_BUTTONS = "reader_zoom_buttons"
|
||||
const val KEY_READER_CONTROL_LTR = "reader_taps_ltr"
|
||||
const val KEY_READER_FULLSCREEN = "reader_fullscreen"
|
||||
const val KEY_READER_VOLUME_BUTTONS = "reader_volume_buttons"
|
||||
const val KEY_TRACKER_ENABLED = "tracker_enabled"
|
||||
@@ -505,6 +554,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_READER_MODE = "reader_mode"
|
||||
const val KEY_READER_MODE_DETECT = "reader_mode_detect"
|
||||
const val KEY_APP_PASSWORD = "app_password"
|
||||
const val KEY_APP_PASSWORD_NUMERIC = "app_password_num"
|
||||
const val KEY_PROTECT_APP = "protect_app"
|
||||
const val KEY_PROTECT_APP_BIOMETRIC = "protect_app_bio"
|
||||
const val KEY_APP_VERSION = "app_version"
|
||||
@@ -518,6 +568,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_HISTORY_GROUPING = "history_grouping"
|
||||
const val KEY_READING_INDICATORS = "reading_indicators"
|
||||
const val KEY_REVERSE_CHAPTERS = "reverse_chapters"
|
||||
const val KEY_GRID_VIEW_CHAPTERS = "grid_view_chapters"
|
||||
const val KEY_HISTORY_EXCLUDE_NSFW = "history_exclude_nsfw"
|
||||
const val KEY_PAGES_NUMBERS = "pages_numbers"
|
||||
const val KEY_SCREENSHOTS_POLICY = "screenshots_policy"
|
||||
@@ -532,6 +583,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_MAL = "mal"
|
||||
const val KEY_KITSU = "kitsu"
|
||||
const val KEY_DOWNLOADS_WIFI = "downloads_wifi"
|
||||
const val KEY_DOWNLOADS_FORMAT = "downloads_format"
|
||||
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
|
||||
const val KEY_DOH = "doh"
|
||||
const val KEY_EXIT_CONFIRM = "exit_confirm"
|
||||
@@ -573,6 +625,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_DISABLE_NSFW = "no_nsfw"
|
||||
const val KEY_RELATED_MANGA = "related_manga"
|
||||
const val KEY_NAV_MAIN = "nav_main"
|
||||
const val KEY_NAV_LABELS = "nav_labels"
|
||||
const val KEY_32BIT_COLOR = "enhanced_colors"
|
||||
const val KEY_SOURCES_ORDER = "sources_sort_order"
|
||||
const val KEY_SOURCES_CATALOG = "sources_catalog"
|
||||
@@ -581,10 +634,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
||||
const val KEY_CF_INVERTED = "cf_inverted"
|
||||
const val KEY_CF_GRAYSCALE = "cf_grayscale"
|
||||
const val KEY_IGNORE_DOZE = "ignore_dose"
|
||||
const val KEY_PAGES_TAB = "pages_tab"
|
||||
const val KEY_DETAILS_TAB = "details_tab"
|
||||
const val KEY_READING_TIME = "reading_time"
|
||||
|
||||
// About
|
||||
const val KEY_PAGES_SAVE_DIR = "pages_dir"
|
||||
const val KEY_PAGES_SAVE_ASK = "pages_dir_ask"
|
||||
const val KEY_STATS_ENABLED = "stats_on"
|
||||
const val KEY_APP_UPDATE = "app_update"
|
||||
const val KEY_APP_TRANSLATION = "about_app_translation"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
enum class DownloadFormat {
|
||||
|
||||
AUTOMATIC,
|
||||
SINGLE_CBZ,
|
||||
MULTIPLE_CBZ,
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
package org.koitharu.kotatsu.core.prefs
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
|
||||
import androidx.core.content.edit
|
||||
import okhttp3.internal.isSensitiveHeader
|
||||
import org.koitharu.kotatsu.core.util.ext.getEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.putEnumValue
|
||||
import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue
|
||||
import org.koitharu.kotatsu.parsers.config.ConfigKey
|
||||
import org.koitharu.kotatsu.parsers.config.MangaSourceConfig
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||
|
||||
private const val KEY_SORT_ORDER = "sort_order"
|
||||
private const val KEY_SLOWDOWN = "slowdown"
|
||||
|
||||
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
|
||||
|
||||
private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE)
|
||||
@@ -27,9 +27,13 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T> get(key: ConfigKey<T>): T {
|
||||
return when (key) {
|
||||
is ConfigKey.UserAgent -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
|
||||
is ConfigKey.UserAgent -> prefs.getString(key.key, key.defaultValue)
|
||||
.ifNullOrEmpty { key.defaultValue }
|
||||
.sanitizeHeaderValue()
|
||||
|
||||
is ConfigKey.Domain -> prefs.getString(key.key, key.defaultValue).ifNullOrEmpty { key.defaultValue }
|
||||
is ConfigKey.ShowSuspiciousContent -> prefs.getBoolean(key.key, key.defaultValue)
|
||||
is ConfigKey.SplitByTranslations -> prefs.getBoolean(key.key, key.defaultValue)
|
||||
} as T
|
||||
}
|
||||
|
||||
@@ -37,7 +41,22 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
||||
when (key) {
|
||||
is ConfigKey.Domain -> putString(key.key, value as String?)
|
||||
is ConfigKey.ShowSuspiciousContent -> putBoolean(key.key, value as Boolean)
|
||||
is ConfigKey.UserAgent -> putString(key.key, value as String?)
|
||||
is ConfigKey.UserAgent -> putString(key.key, (value as String?)?.sanitizeHeaderValue())
|
||||
is ConfigKey.SplitByTranslations -> putBoolean(key.key, value as Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
fun subscribe(listener: OnSharedPreferenceChangeListener) {
|
||||
prefs.registerOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
fun unsubscribe(listener: OnSharedPreferenceChangeListener) {
|
||||
prefs.unregisterOnSharedPreferenceChangeListener(listener)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val KEY_SORT_ORDER = "sort_order"
|
||||
const val KEY_SLOWDOWN = "slowdown"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
@@ -29,6 +30,7 @@ import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
import org.koitharu.kotatsu.core.ui.util.BaseActivityEntryPoint
|
||||
import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable
|
||||
|
||||
@Suppress("LeakingThis")
|
||||
abstract class BaseActivity<B : ViewBinding> :
|
||||
@@ -58,11 +60,11 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
if (isAmoledTheme) {
|
||||
setTheme(R.style.ThemeOverlay_Kotatsu_Amoled)
|
||||
}
|
||||
putDataToExtras(intent)
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
insetsDelegate.handleImeInsets = true
|
||||
insetsDelegate.addInsetsListener(this)
|
||||
putDataToExtras(intent)
|
||||
}
|
||||
|
||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||
@@ -125,11 +127,13 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
ColorUtils.compositeColors(
|
||||
ContextCompat.getColor(this, com.google.android.material.R.color.m3_appbar_overlay_color),
|
||||
getThemeColor(R.attr.m3ColorBackground),
|
||||
getThemeColor(com.google.android.material.R.attr.colorSurface),
|
||||
)
|
||||
} else {
|
||||
ContextCompat.getColor(this, R.color.kotatsu_m3_background)
|
||||
ContextCompat.getColor(this, R.color.kotatsu_background)
|
||||
}
|
||||
defaultStatusBarColor = window.statusBarColor
|
||||
window.statusBarColor = actionModeColor
|
||||
val insets = ViewCompat.getRootWindowInsets(viewBinding.root)
|
||||
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
|
||||
findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar).apply {
|
||||
@@ -138,8 +142,6 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
topMargin = insets.top
|
||||
}
|
||||
}
|
||||
defaultStatusBarColor = window.statusBarColor
|
||||
window.statusBarColor = actionModeColor
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
@@ -164,6 +166,21 @@ abstract class BaseActivity<B : ViewBinding> :
|
||||
intent?.putExtra(EXTRA_DATA, intent.data)
|
||||
}
|
||||
|
||||
protected fun setContentViewWebViewSafe(viewBindingProducer: () -> B): Boolean {
|
||||
return try {
|
||||
setContentView(viewBindingProducer())
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
if (e.isWebViewUnavailable()) {
|
||||
Toast.makeText(this, R.string.web_view_unavailable, Toast.LENGTH_LONG).show()
|
||||
finishAfterTransition()
|
||||
false
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val EXTRA_DATA = "data"
|
||||
|
||||
@@ -29,8 +29,9 @@ open class BaseListAdapter<T : ListModel> : AsyncListDifferDelegationAdapter<T>(
|
||||
return this
|
||||
}
|
||||
|
||||
fun addListListener(listListener: ListListener<T>) {
|
||||
fun addListListener(listListener: ListListener<T>): BaseListAdapter<T> {
|
||||
differ.addListListener(listListener)
|
||||
return this
|
||||
}
|
||||
|
||||
fun removeListListener(listListener: ListListener<T>) {
|
||||
|
||||
@@ -68,6 +68,13 @@ abstract class BaseViewModel : ViewModel() {
|
||||
errorEvent.call(error)
|
||||
}
|
||||
|
||||
protected inline suspend fun <T> withLoading(block: () -> T): T = try {
|
||||
loadingCounter.increment()
|
||||
block()
|
||||
} finally {
|
||||
loadingCounter.decrement()
|
||||
}
|
||||
|
||||
protected fun MutableStateFlow<Int>.increment() = update { it + 1 }
|
||||
|
||||
protected fun MutableStateFlow<Int>.decrement() = update { it - 1 }
|
||||
|
||||
@@ -68,6 +68,14 @@ class RecyclerViewAlertDialog private constructor(
|
||||
return this
|
||||
}
|
||||
|
||||
fun setNeutralButton(
|
||||
@StringRes textId: Int,
|
||||
listener: DialogInterface.OnClickListener,
|
||||
): Builder<T> {
|
||||
delegate.setNeutralButton(textId, listener)
|
||||
return this
|
||||
}
|
||||
|
||||
fun setCancelable(isCancelable: Boolean): Builder<T> {
|
||||
delegate.setCancelable(isCancelable)
|
||||
return this
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.koitharu.kotatsu.core.ui.image
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import coil.target.GenericViewTarget
|
||||
import com.google.android.material.chip.Chip
|
||||
|
||||
class ChipIconTarget(override val view: Chip) : GenericViewTarget<Chip>() {
|
||||
|
||||
override var drawable: Drawable?
|
||||
get() = view.chipIcon
|
||||
set(value) {
|
||||
view.chipIcon = value
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,10 @@ import android.graphics.RectF
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.graphics.withClip
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import org.koitharu.kotatsu.R
|
||||
import kotlin.math.absoluteValue
|
||||
import org.koitharu.kotatsu.core.util.KotatsuColors
|
||||
|
||||
class FaviconDrawable(
|
||||
context: Context,
|
||||
@@ -44,7 +43,7 @@ class FaviconDrawable(
|
||||
}
|
||||
paint.textAlign = Paint.Align.CENTER
|
||||
paint.isFakeBoldText = true
|
||||
colorForeground = MaterialColors.harmonize(colorOfString(name), colorBackground)
|
||||
colorForeground = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground)
|
||||
}
|
||||
|
||||
override fun draw(canvas: Canvas) {
|
||||
@@ -104,9 +103,4 @@ class FaviconDrawable(
|
||||
paint.getTextBounds(text, 0, text.length, tempRect)
|
||||
return testTextSize * width / tempRect.width()
|
||||
}
|
||||
|
||||
private fun colorOfString(str: String): Int {
|
||||
val hue = (str.hashCode() % 360).absoluteValue.toFloat()
|
||||
return ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package org.koitharu.kotatsu.core.ui.list
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
@@ -20,7 +19,7 @@ private const val KEY_SELECTION = "selection"
|
||||
private const val PROVIDER_NAME = "selection_decoration"
|
||||
|
||||
class ListSelectionController(
|
||||
private val activity: Activity,
|
||||
private val appCompatDelegate: AppCompatDelegate,
|
||||
private val decoration: AbstractSelectionItemDecoration,
|
||||
private val registryOwner: SavedStateRegistryOwner,
|
||||
private val callback: Callback2,
|
||||
@@ -108,7 +107,7 @@ class ListSelectionController(
|
||||
|
||||
private fun startActionMode() {
|
||||
if (actionMode == null) {
|
||||
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
|
||||
actionMode = appCompatDelegate.startSupportActionMode(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ abstract class AbstractSelectionItemDecoration : RecyclerView.ItemDecoration() {
|
||||
canvas.restoreToCount(checkpoint)
|
||||
}
|
||||
|
||||
protected open fun getItemId(parent: RecyclerView, child: View) = parent.getChildItemId(child)
|
||||
abstract fun getItemId(parent: RecyclerView, child: View): Long
|
||||
|
||||
protected open fun onDrawBackground(
|
||||
canvas: Canvas,
|
||||
|
||||
@@ -1,25 +1,39 @@
|
||||
package org.koitharu.kotatsu.core.ui.sheet
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.appcompat.app.AppCompatDialog
|
||||
import androidx.appcompat.app.AppCompatDialogFragment
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.appcompat.widget.ActionBarContextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.sidesheet.SideSheetDialog
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
|
||||
|
||||
private var waitingForDismissAllowingStateLoss = false
|
||||
private var isFitToContentsDisabled = false
|
||||
private var defaultStatusBarColor = Color.TRANSPARENT
|
||||
|
||||
var viewBinding: B? = null
|
||||
private set
|
||||
@@ -31,12 +45,19 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
|
||||
protected val behavior: AdaptiveSheetBehavior?
|
||||
get() = AdaptiveSheetBehavior.from(dialog)
|
||||
|
||||
@JvmField
|
||||
val actionModeDelegate = ActionModeDelegate()
|
||||
|
||||
val isExpanded: Boolean
|
||||
get() = behavior?.state == AdaptiveSheetBehavior.STATE_EXPANDED
|
||||
|
||||
val onBackPressedDispatcher: OnBackPressedDispatcher
|
||||
get() = requireComponentDialog().onBackPressedDispatcher
|
||||
|
||||
var isLocked = false
|
||||
private set
|
||||
private var lockCounter = 0
|
||||
|
||||
final override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
@@ -60,11 +81,45 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val context = requireContext()
|
||||
return if (context.resources.getBoolean(R.bool.is_tablet)) {
|
||||
SideSheetDialog(context, theme)
|
||||
val dialog = if (context.resources.getBoolean(R.bool.is_tablet)) {
|
||||
SideSheetDialogImpl(context, theme)
|
||||
} else {
|
||||
BottomSheetDialog(context, theme)
|
||||
BottomSheetDialogImpl(context, theme)
|
||||
}
|
||||
dialog.onBackPressedDispatcher.addCallback(actionModeDelegate)
|
||||
return dialog
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
protected open fun dispatchSupportActionModeStarted(mode: ActionMode) {
|
||||
actionModeDelegate.onSupportActionModeStarted(mode)
|
||||
val ctx = requireContext()
|
||||
val actionModeColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
ColorUtils.compositeColors(
|
||||
ContextCompat.getColor(ctx, com.google.android.material.R.color.m3_appbar_overlay_color),
|
||||
ctx.getThemeColor(com.google.android.material.R.attr.colorSurface),
|
||||
)
|
||||
} else {
|
||||
ContextCompat.getColor(ctx, R.color.kotatsu_surface)
|
||||
}
|
||||
dialog?.window?.let {
|
||||
defaultStatusBarColor = it.statusBarColor
|
||||
it.statusBarColor = actionModeColor
|
||||
}
|
||||
val insets = ViewCompat.getRootWindowInsets(requireView())
|
||||
?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: return
|
||||
dialog?.window?.decorView?.findViewById<ActionBarContextView?>(androidx.appcompat.R.id.action_mode_bar)?.apply {
|
||||
setBackgroundColor(actionModeColor)
|
||||
updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
topMargin = insets.top
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
protected open fun dispatchSupportActionModeFinished(mode: ActionMode) {
|
||||
actionModeDelegate.onSupportActionModeFinished(mode)
|
||||
dialog?.window?.statusBarColor = defaultStatusBarColor
|
||||
}
|
||||
|
||||
fun addSheetCallback(callback: AdaptiveSheetCallback) {
|
||||
@@ -81,7 +136,16 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
|
||||
|
||||
protected open fun onViewBindingCreated(binding: B, savedInstanceState: Bundle?) = Unit
|
||||
|
||||
fun startSupportActionMode(callback: ActionMode.Callback): ActionMode? {
|
||||
val appCompatDialog = dialog as? AppCompatDialog ?: return null
|
||||
return appCompatDialog.delegate.startSupportActionMode(callback)
|
||||
}
|
||||
|
||||
protected fun setExpanded(isExpanded: Boolean, isLocked: Boolean) {
|
||||
this.isLocked = isLocked
|
||||
if (!isLocked) {
|
||||
lockCounter = 0
|
||||
}
|
||||
val b = behavior ?: return
|
||||
if (isExpanded) {
|
||||
b.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
@@ -109,6 +173,20 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
open fun expandAndLock() {
|
||||
lockCounter++
|
||||
setExpanded(isExpanded = true, isLocked = true)
|
||||
}
|
||||
|
||||
@CallSuper
|
||||
open fun unlock() {
|
||||
lockCounter--
|
||||
if (lockCounter <= 0) {
|
||||
setExpanded(isExpanded, false)
|
||||
}
|
||||
}
|
||||
|
||||
fun requireViewBinding(): B = checkNotNull(viewBinding) {
|
||||
"Fragment $this did not return a ViewBinding from onCreateView() or this was called before onCreateView()."
|
||||
}
|
||||
@@ -171,4 +249,38 @@ abstract class BaseAdaptiveSheet<B : ViewBinding> : AppCompatDialogFragment() {
|
||||
|
||||
override fun onSlide(sheet: View, slideOffset: Float) {}
|
||||
}
|
||||
|
||||
private inner class SideSheetDialogImpl(context: Context, theme: Int) : SideSheetDialog(context, theme) {
|
||||
|
||||
override fun onSupportActionModeStarted(mode: ActionMode?) {
|
||||
super.onSupportActionModeStarted(mode)
|
||||
if (mode != null) {
|
||||
dispatchSupportActionModeStarted(mode)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSupportActionModeFinished(mode: ActionMode?) {
|
||||
super.onSupportActionModeFinished(mode)
|
||||
if (mode != null) {
|
||||
dispatchSupportActionModeFinished(mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class BottomSheetDialogImpl(context: Context, theme: Int) : BottomSheetDialog(context, theme) {
|
||||
|
||||
override fun onSupportActionModeStarted(mode: ActionMode?) {
|
||||
super.onSupportActionModeStarted(mode)
|
||||
if (mode != null) {
|
||||
dispatchSupportActionModeStarted(mode)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSupportActionModeFinished(mode: ActionMode?) {
|
||||
super.onSupportActionModeFinished(mode)
|
||||
if (mode != null) {
|
||||
dispatchSupportActionModeFinished(mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,16 @@ import org.koitharu.kotatsu.R
|
||||
|
||||
class ReversibleActionObserver(
|
||||
private val snackbarHost: View,
|
||||
private val snackbarAnchor: View? = null,
|
||||
) : FlowCollector<ReversibleAction> {
|
||||
|
||||
override suspend fun emit(value: ReversibleAction) {
|
||||
val handle = value.handle
|
||||
val length = if (handle == null) Snackbar.LENGTH_SHORT else Snackbar.LENGTH_LONG
|
||||
val snackbar = Snackbar.make(snackbarHost, value.stringResId, length)
|
||||
if (snackbarAnchor?.isShown == true) {
|
||||
snackbar.anchorView = snackbarAnchor
|
||||
}
|
||||
if (handle != null) {
|
||||
snackbar.setAction(R.string.undo) { handle.reverseAsync() }
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import com.google.android.material.imageview.ShapeableImageView
|
||||
import org.koitharu.kotatsu.R
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private const val ASPECT_RATIO_HEIGHT = 18f
|
||||
private const val ASPECT_RATIO_WIDTH = 13f
|
||||
private const val ASPECT_RATIO_HEIGHT = 3f
|
||||
private const val ASPECT_RATIO_WIDTH = 2f
|
||||
|
||||
class CoverImageView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
|
||||
@@ -1,397 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.ui.widgets
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.CornerPathEffect
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Typeface
|
||||
import android.os.Build
|
||||
import android.os.Parcelable
|
||||
import android.text.Layout
|
||||
import android.text.StaticLayout
|
||||
import android.text.TextDirectionHeuristic
|
||||
import android.text.TextDirectionHeuristics
|
||||
import android.text.TextPaint
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.draw
|
||||
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
||||
import org.koitharu.kotatsu.core.util.ext.resolveDp
|
||||
import org.koitharu.kotatsu.core.util.ext.resolveSp
|
||||
|
||||
class PieChart @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : View(context, attrs, defStyleAttr), PieChartInterface {
|
||||
|
||||
private var marginTextFirst: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_1)
|
||||
private var marginTextSecond: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_2)
|
||||
private var marginTextThird: Float = context.resources.resolveDp(DEFAULT_MARGIN_TEXT_3)
|
||||
private var marginSmallCircle: Float = context.resources.resolveDp(DEFAULT_MARGIN_SMALL_CIRCLE)
|
||||
private val marginText: Float = marginTextFirst + marginTextSecond
|
||||
private val circleRect = RectF()
|
||||
private var circleStrokeWidth: Float = context.resources.resolveDp(6f)
|
||||
private var circleRadius: Float = 0f
|
||||
private var circlePadding: Float = context.resources.resolveDp(8f)
|
||||
private var circlePaintRoundSize: Boolean = true
|
||||
private var circleSectionSpace: Float = 3f
|
||||
private var circleCenterX: Float = 0f
|
||||
private var circleCenterY: Float = 0f
|
||||
private var numberTextPaint: TextPaint = TextPaint()
|
||||
private var descriptionTextPain: TextPaint = TextPaint()
|
||||
private var amountTextPaint: TextPaint = TextPaint()
|
||||
private var textStartX: Float = 0f
|
||||
private var textStartY: Float = 0f
|
||||
private var textHeight: Int = 0
|
||||
private var textCircleRadius: Float = context.resources.resolveDp(4f)
|
||||
private var textAmountStr: String = ""
|
||||
private var textAmountY: Float = 0f
|
||||
private var textAmountXNumber: Float = 0f
|
||||
private var textAmountXDescription: Float = 0f
|
||||
private var textAmountYDescription: Float = 0f
|
||||
private var totalAmount: Int = 0
|
||||
private var pieChartColors: List<String> = listOf()
|
||||
private var percentageCircleList: List<PieChartModel> = listOf()
|
||||
private var textRowList: MutableList<StaticLayout> = mutableListOf()
|
||||
private var dataList: List<Pair<Int, String>> = listOf()
|
||||
private var animationSweepAngle: Int = 0
|
||||
|
||||
init {
|
||||
var textAmountSize: Float = context.resources.resolveSp(22f)
|
||||
var textNumberSize: Float = context.resources.resolveSp(20f)
|
||||
var textDescriptionSize: Float = context.resources.resolveSp(14f)
|
||||
var textAmountColor: Int = Color.WHITE
|
||||
var textNumberColor: Int = Color.WHITE
|
||||
var textDescriptionColor: Int = Color.GRAY
|
||||
|
||||
if (attrs != null) {
|
||||
val typeArray = context.obtainStyledAttributes(attrs, R.styleable.PieChart)
|
||||
|
||||
val colorResId = typeArray.getResourceId(R.styleable.PieChart_pieChartColors, 0)
|
||||
pieChartColors = typeArray.resources.getStringArray(colorResId).toList()
|
||||
|
||||
marginTextFirst = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextFirst, marginTextFirst)
|
||||
marginTextSecond = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextSecond, marginTextSecond)
|
||||
marginTextThird = typeArray.getDimension(R.styleable.PieChart_pieChartMarginTextThird, marginTextThird)
|
||||
marginSmallCircle =
|
||||
typeArray.getDimension(R.styleable.PieChart_pieChartMarginSmallCircle, marginSmallCircle)
|
||||
|
||||
circleStrokeWidth =
|
||||
typeArray.getDimension(R.styleable.PieChart_pieChartCircleStrokeWidth, circleStrokeWidth)
|
||||
circlePadding = typeArray.getDimension(R.styleable.PieChart_pieChartCirclePadding, circlePadding)
|
||||
circlePaintRoundSize =
|
||||
typeArray.getBoolean(R.styleable.PieChart_pieChartCirclePaintRoundSize, circlePaintRoundSize)
|
||||
circleSectionSpace = typeArray.getFloat(R.styleable.PieChart_pieChartCircleSectionSpace, circleSectionSpace)
|
||||
|
||||
textCircleRadius = typeArray.getDimension(R.styleable.PieChart_pieChartTextCircleRadius, textCircleRadius)
|
||||
textAmountSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextAmountSize, textAmountSize)
|
||||
textNumberSize = typeArray.getDimension(R.styleable.PieChart_pieChartTextNumberSize, textNumberSize)
|
||||
textDescriptionSize =
|
||||
typeArray.getDimension(R.styleable.PieChart_pieChartTextDescriptionSize, textDescriptionSize)
|
||||
textAmountColor = typeArray.getColor(R.styleable.PieChart_pieChartTextAmountColor, textAmountColor)
|
||||
textNumberColor = typeArray.getColor(R.styleable.PieChart_pieChartTextNumberColor, textNumberColor)
|
||||
textDescriptionColor =
|
||||
typeArray.getColor(R.styleable.PieChart_pieChartTextDescriptionColor, textDescriptionColor)
|
||||
textAmountStr = typeArray.getString(R.styleable.PieChart_pieChartTextAmount) ?: ""
|
||||
|
||||
typeArray.recycle()
|
||||
}
|
||||
|
||||
circlePadding += circleStrokeWidth
|
||||
|
||||
// Инициализация кистей View
|
||||
initPaints(amountTextPaint, textAmountSize, textAmountColor)
|
||||
initPaints(numberTextPaint, textNumberSize, textNumberColor)
|
||||
initPaints(descriptionTextPain, textDescriptionSize, textDescriptionColor, true)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
textRowList.clear()
|
||||
|
||||
val initSizeWidth = resolveDefaultSize(widthMeasureSpec, DEFAULT_VIEW_SIZE_WIDTH)
|
||||
|
||||
val textTextWidth = (initSizeWidth * TEXT_WIDTH_PERCENT)
|
||||
val initSizeHeight = calculateViewHeight(heightMeasureSpec, textTextWidth.toInt())
|
||||
|
||||
textStartX = initSizeWidth - textTextWidth.toFloat()
|
||||
textStartY = initSizeHeight.toFloat() / 2 - textHeight / 2
|
||||
|
||||
calculateCircleRadius(initSizeWidth, initSizeHeight)
|
||||
|
||||
setMeasuredDimension(initSizeWidth, initSizeHeight)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
|
||||
drawCircle(canvas)
|
||||
drawText(canvas)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(state: Parcelable?) {
|
||||
val pieChartState = state as? PieChartState
|
||||
super.onRestoreInstanceState(pieChartState?.superState ?: state)
|
||||
|
||||
dataList = pieChartState?.dataList ?: listOf()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable {
|
||||
val superState = super.onSaveInstanceState()
|
||||
return PieChartState(superState, dataList)
|
||||
}
|
||||
|
||||
override fun setDataChart(list: List<Pair<Int, String>>) {
|
||||
dataList = list
|
||||
calculatePercentageOfData()
|
||||
}
|
||||
|
||||
override fun startAnimation() {
|
||||
val animator = ValueAnimator.ofInt(0, 360).apply {
|
||||
duration = context.getAnimationDuration(android.R.integer.config_longAnimTime)
|
||||
interpolator = FastOutSlowInInterpolator()
|
||||
addUpdateListener { valueAnimator ->
|
||||
animationSweepAngle = valueAnimator.animatedValue as Int
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
animator.start()
|
||||
}
|
||||
|
||||
private fun drawCircle(canvas: Canvas) {
|
||||
for (percent in percentageCircleList) {
|
||||
if (animationSweepAngle > percent.percentToStartAt + percent.percentOfCircle) {
|
||||
canvas.drawArc(circleRect, percent.percentToStartAt, percent.percentOfCircle, false, percent.paint)
|
||||
} else if (animationSweepAngle > percent.percentToStartAt) {
|
||||
canvas.drawArc(
|
||||
circleRect,
|
||||
percent.percentToStartAt,
|
||||
animationSweepAngle - percent.percentToStartAt,
|
||||
false,
|
||||
percent.paint,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawText(canvas: Canvas) {
|
||||
var textBuffY = textStartY
|
||||
textRowList.forEachIndexed { index, staticLayout ->
|
||||
if (index % 2 == 0) {
|
||||
staticLayout.draw(canvas, textStartX + marginSmallCircle + textCircleRadius, textBuffY)
|
||||
canvas.drawCircle(
|
||||
textStartX + marginSmallCircle / 2,
|
||||
textBuffY + staticLayout.height / 2 + textCircleRadius / 2,
|
||||
textCircleRadius,
|
||||
Paint().apply { color = Color.parseColor(pieChartColors[(index / 2) % pieChartColors.size]) },
|
||||
)
|
||||
textBuffY += staticLayout.height + marginTextFirst
|
||||
} else {
|
||||
staticLayout.draw(canvas, textStartX, textBuffY)
|
||||
textBuffY += staticLayout.height + marginTextSecond
|
||||
}
|
||||
}
|
||||
|
||||
canvas.drawText(totalAmount.toString(), textAmountXNumber, textAmountY, amountTextPaint)
|
||||
canvas.drawText(textAmountStr, textAmountXDescription, textAmountYDescription, descriptionTextPain)
|
||||
}
|
||||
|
||||
private fun initPaints(textPaint: TextPaint, textSize: Float, textColor: Int, isDescription: Boolean = false) {
|
||||
textPaint.color = textColor
|
||||
textPaint.textSize = textSize
|
||||
textPaint.isAntiAlias = true
|
||||
|
||||
if (!isDescription) textPaint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
}
|
||||
|
||||
private fun resolveDefaultSize(spec: Int, defValue: Int): Int {
|
||||
return when (MeasureSpec.getMode(spec)) {
|
||||
MeasureSpec.UNSPECIFIED -> resources.resolveDp(defValue)
|
||||
else -> MeasureSpec.getSize(spec)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun calculateViewHeight(heightMeasureSpec: Int, textWidth: Int): Int {
|
||||
val initSizeHeight = resolveDefaultSize(heightMeasureSpec, DEFAULT_VIEW_SIZE_HEIGHT)
|
||||
textHeight = (dataList.size * marginText + getTextViewHeight(textWidth)).toInt()
|
||||
|
||||
val textHeightWithPadding = textHeight + paddingTop + paddingBottom
|
||||
return if (textHeightWithPadding > initSizeHeight) textHeightWithPadding else initSizeHeight
|
||||
}
|
||||
|
||||
private fun calculateCircleRadius(width: Int, height: Int) {
|
||||
val circleViewWidth = (width * CIRCLE_WIDTH_PERCENT)
|
||||
circleRadius = if (circleViewWidth > height) {
|
||||
(height.toFloat() - circlePadding) / 2
|
||||
} else {
|
||||
circleViewWidth.toFloat() / 2
|
||||
}
|
||||
|
||||
with(circleRect) {
|
||||
left = circlePadding
|
||||
top = height / 2 - circleRadius
|
||||
right = circleRadius * 2 + circlePadding
|
||||
bottom = height / 2 + circleRadius
|
||||
}
|
||||
|
||||
circleCenterX = (circleRadius * 2 + circlePadding + circlePadding) / 2
|
||||
circleCenterY = (height / 2 + circleRadius + (height / 2 - circleRadius)) / 2
|
||||
|
||||
textAmountY = circleCenterY
|
||||
|
||||
val sizeTextAmountNumber = getWidthOfAmountText(
|
||||
totalAmount.toString(),
|
||||
amountTextPaint,
|
||||
)
|
||||
|
||||
textAmountXNumber = circleCenterX - sizeTextAmountNumber.width() / 2
|
||||
textAmountXDescription = circleCenterX - getWidthOfAmountText(textAmountStr, descriptionTextPain).width() / 2
|
||||
textAmountYDescription = circleCenterY + sizeTextAmountNumber.height() + marginTextThird
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun getTextViewHeight(maxWidth: Int): Int {
|
||||
var textHeight = 0
|
||||
dataList.forEach {
|
||||
val textLayoutNumber = getMultilineText(
|
||||
text = it.first.toString(),
|
||||
textPaint = numberTextPaint,
|
||||
width = maxWidth,
|
||||
)
|
||||
val textLayoutDescription = getMultilineText(
|
||||
text = it.second,
|
||||
textPaint = descriptionTextPain,
|
||||
width = maxWidth,
|
||||
)
|
||||
textRowList.apply {
|
||||
add(textLayoutNumber)
|
||||
add(textLayoutDescription)
|
||||
}
|
||||
textHeight += textLayoutNumber.height + textLayoutDescription.height
|
||||
}
|
||||
|
||||
return textHeight
|
||||
}
|
||||
|
||||
private fun calculatePercentageOfData() {
|
||||
totalAmount = dataList.fold(0) { res, value -> res + value.first }
|
||||
|
||||
var startAt = circleSectionSpace
|
||||
percentageCircleList = dataList.mapIndexed { index, pair ->
|
||||
var percent = pair.first * 100 / totalAmount.toFloat() - circleSectionSpace
|
||||
percent = if (percent < 0f) 0f else percent
|
||||
|
||||
val resultModel = PieChartModel(
|
||||
percentOfCircle = percent,
|
||||
percentToStartAt = startAt,
|
||||
colorOfLine = Color.parseColor(pieChartColors[index % pieChartColors.size]),
|
||||
stroke = circleStrokeWidth,
|
||||
paintRound = circlePaintRoundSize,
|
||||
)
|
||||
if (percent != 0f) startAt += percent + circleSectionSpace
|
||||
resultModel
|
||||
}
|
||||
}
|
||||
|
||||
private fun getWidthOfAmountText(text: String, textPaint: TextPaint): Rect {
|
||||
val bounds = Rect()
|
||||
textPaint.getTextBounds(text, 0, text.length, bounds)
|
||||
return bounds
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun getMultilineText(
|
||||
text: CharSequence,
|
||||
textPaint: TextPaint,
|
||||
width: Int,
|
||||
start: Int = 0,
|
||||
end: Int = text.length,
|
||||
alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL,
|
||||
textDir: TextDirectionHeuristic = TextDirectionHeuristics.LTR,
|
||||
spacingMult: Float = 1f,
|
||||
spacingAdd: Float = 0f
|
||||
): StaticLayout {
|
||||
|
||||
return StaticLayout.Builder
|
||||
.obtain(text, start, end, textPaint, width)
|
||||
.setAlignment(alignment)
|
||||
.setTextDirection(textDir)
|
||||
.setLineSpacing(spacingAdd, spacingMult)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_MARGIN_TEXT_1 = 2f
|
||||
private const val DEFAULT_MARGIN_TEXT_2 = 10f
|
||||
private const val DEFAULT_MARGIN_TEXT_3 = 2f
|
||||
private const val DEFAULT_MARGIN_SMALL_CIRCLE = 12f
|
||||
|
||||
private const val TEXT_WIDTH_PERCENT = 0.40
|
||||
private const val CIRCLE_WIDTH_PERCENT = 0.50
|
||||
|
||||
const val DEFAULT_VIEW_SIZE_HEIGHT = 150
|
||||
const val DEFAULT_VIEW_SIZE_WIDTH = 250
|
||||
}
|
||||
}
|
||||
|
||||
interface PieChartInterface {
|
||||
|
||||
fun setDataChart(list: List<Pair<Int, String>>)
|
||||
|
||||
fun startAnimation()
|
||||
}
|
||||
|
||||
data class PieChartModel(
|
||||
var percentOfCircle: Float = 0f,
|
||||
var percentToStartAt: Float = 0f,
|
||||
var colorOfLine: Int = 0,
|
||||
var stroke: Float = 0f,
|
||||
var paint: Paint = Paint(),
|
||||
var paintRound: Boolean = true
|
||||
) {
|
||||
|
||||
init {
|
||||
if (percentOfCircle < 0 || percentOfCircle > 100) {
|
||||
percentOfCircle = 100f
|
||||
}
|
||||
|
||||
percentOfCircle = 360 * percentOfCircle / 100
|
||||
|
||||
if (percentToStartAt < 0 || percentToStartAt > 100) {
|
||||
percentToStartAt = 0f
|
||||
}
|
||||
|
||||
percentToStartAt = 360 * percentToStartAt / 100
|
||||
|
||||
if (colorOfLine == 0) {
|
||||
colorOfLine = Color.parseColor("#000000")
|
||||
}
|
||||
|
||||
paint = Paint()
|
||||
paint.color = colorOfLine
|
||||
paint.isAntiAlias = true
|
||||
paint.style = Paint.Style.STROKE
|
||||
paint.strokeWidth = stroke
|
||||
paint.isDither = true
|
||||
|
||||
if (paintRound) {
|
||||
paint.strokeJoin = Paint.Join.ROUND
|
||||
paint.strokeCap = Paint.Cap.ROUND
|
||||
paint.pathEffect = CornerPathEffect(8f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PieChartState(
|
||||
superSavedState: Parcelable?,
|
||||
val dataList: List<Pair<Int, String>>
|
||||
) : View.BaseSavedState(superSavedState), Parcelable
|
||||
@@ -0,0 +1,155 @@
|
||||
package org.koitharu.kotatsu.core.ui.widgets
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Outline
|
||||
import android.graphics.Paint
|
||||
import android.util.AttributeSet
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewOutlineProvider
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.widget.LinearLayoutCompat
|
||||
import androidx.core.content.withStyledAttributes
|
||||
import androidx.core.view.children
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.resolveDp
|
||||
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class ProgressButton @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
) : LinearLayoutCompat(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener {
|
||||
|
||||
private val textViewTitle = TextView(context)
|
||||
private val textViewSubtitle = TextView(context)
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
private var progress = 0f
|
||||
private var colorBase = context.getThemeColor(materialR.attr.colorPrimaryContainer)
|
||||
private var colorProgress = context.getThemeColor(materialR.attr.colorPrimary)
|
||||
private var colorText = context.getThemeColor(materialR.attr.colorOnPrimaryContainer)
|
||||
private var progressAnimator: ValueAnimator? = null
|
||||
|
||||
var title: CharSequence?
|
||||
get() = textViewTitle.textAndVisible
|
||||
set(value) {
|
||||
textViewTitle.textAndVisible = value
|
||||
}
|
||||
|
||||
var subtitle: CharSequence?
|
||||
get() = textViewSubtitle.textAndVisible
|
||||
set(value) {
|
||||
textViewSubtitle.textAndVisible = value
|
||||
}
|
||||
|
||||
init {
|
||||
orientation = VERTICAL
|
||||
outlineProvider = OutlineProvider()
|
||||
clipToOutline = true
|
||||
|
||||
context.withStyledAttributes(attrs, R.styleable.ProgressButton, defStyleAttr) {
|
||||
val textAppearanceFallback = androidx.appcompat.R.style.TextAppearance_AppCompat
|
||||
TextViewCompat.setTextAppearance(
|
||||
textViewTitle,
|
||||
getResourceId(R.styleable.ProgressButton_titleTextAppearance, textAppearanceFallback),
|
||||
)
|
||||
TextViewCompat.setTextAppearance(
|
||||
textViewSubtitle,
|
||||
getResourceId(R.styleable.ProgressButton_subtitleTextAppearance, textAppearanceFallback),
|
||||
)
|
||||
textViewTitle.text = getText(R.styleable.ProgressButton_title)
|
||||
textViewSubtitle.text = getText(R.styleable.ProgressButton_subtitle)
|
||||
colorBase = getColor(R.styleable.ProgressButton_baseColor, colorBase)
|
||||
colorProgress = getColor(R.styleable.ProgressButton_progressColor, colorProgress)
|
||||
colorText = getColor(R.styleable.ProgressButton_textColor, colorText)
|
||||
textViewTitle.setTextColor(colorText)
|
||||
textViewSubtitle.setTextColor(colorText)
|
||||
progress = getInt(R.styleable.ProgressButton_android_progress, 0).toFloat() /
|
||||
getInt(R.styleable.ProgressButton_android_max, 100).toFloat()
|
||||
}
|
||||
|
||||
addView(textViewTitle, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
|
||||
addView(
|
||||
textViewSubtitle,
|
||||
LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).also { lp ->
|
||||
lp.topMargin = context.resources.resolveDp(2)
|
||||
},
|
||||
)
|
||||
|
||||
paint.style = Paint.Style.FILL
|
||||
paint.color = colorProgress
|
||||
paint.alpha = 84 // 255 * 0.33F
|
||||
applyGravity()
|
||||
setWillNotDraw(false)
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
canvas.drawColor(colorBase)
|
||||
canvas.drawRect(0f, 0f, width * progress, height.toFloat(), paint)
|
||||
}
|
||||
|
||||
override fun setGravity(gravity: Int) {
|
||||
super.setGravity(gravity)
|
||||
if (childCount != 0) {
|
||||
applyGravity()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setEnabled(enabled: Boolean) {
|
||||
super.setEnabled(enabled)
|
||||
children.forEach { it.isEnabled = enabled }
|
||||
}
|
||||
|
||||
override fun onAnimationUpdate(animation: ValueAnimator) {
|
||||
progress = animation.animatedValue as Float
|
||||
invalidate()
|
||||
}
|
||||
|
||||
fun setTitle(@StringRes titleResId: Int) {
|
||||
textViewTitle.setTextAndVisible(titleResId)
|
||||
}
|
||||
|
||||
fun setSubtitle(@StringRes titleResId: Int) {
|
||||
textViewSubtitle.setTextAndVisible(titleResId)
|
||||
}
|
||||
|
||||
fun setProgress(value: Float, animate: Boolean) {
|
||||
progressAnimator?.cancel()
|
||||
if (animate) {
|
||||
progressAnimator = ValueAnimator.ofFloat(progress, value).apply {
|
||||
duration = context.getAnimationDuration(android.R.integer.config_shortAnimTime)
|
||||
interpolator = AccelerateDecelerateInterpolator()
|
||||
addUpdateListener(this@ProgressButton)
|
||||
start()
|
||||
}
|
||||
} else {
|
||||
progressAnimator = null
|
||||
progress = value
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyGravity() {
|
||||
val value = (gravity and Gravity.HORIZONTAL_GRAVITY_MASK) or Gravity.CENTER_VERTICAL
|
||||
textViewTitle.gravity = value
|
||||
textViewSubtitle.gravity = value
|
||||
}
|
||||
|
||||
private class OutlineProvider : ViewOutlineProvider() {
|
||||
|
||||
override fun getOutline(view: View, outline: Outline) {
|
||||
outline.setRoundRect(0, 0, view.width, view.height, view.height / 2f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ class TipView @JvmOverloads constructor(
|
||||
val shapeAppearanceModel = ShapeAppearanceModel.builder(context, attrs, defStyleAttr, 0).build()
|
||||
background = MaterialShapeDrawable(shapeAppearanceModel).also {
|
||||
it.fillColor = getColorStateList(R.styleable.TipView_cardBackgroundColor)
|
||||
?: context.getThemeColorStateList(R.attr.m3ColorExploreButton)
|
||||
?: context.getThemeColorStateList(com.google.android.material.R.attr.colorSurfaceContainerHigh)
|
||||
it.strokeWidth = getDimension(R.styleable.TipView_strokeWidth, 0f)
|
||||
it.strokeColor = getColorStateList(R.styleable.TipView_strokeColor)
|
||||
it.elevation = getDimension(R.styleable.TipView_elevation, 0f)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.koitharu.kotatsu.core.util
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import com.google.android.material.R
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import kotlin.math.absoluteValue
|
||||
|
||||
object KotatsuColors {
|
||||
|
||||
@ColorInt
|
||||
fun segmentColor(context: Context, @AttrRes resId: Int): Int {
|
||||
val colorHex = String.format("%06x", context.getThemeColor(resId))
|
||||
val hue = getHue(colorHex)
|
||||
val color = ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
|
||||
val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh)
|
||||
return MaterialColors.harmonize(color, backgroundColor)
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun random(seed: Any): Int {
|
||||
val hue = (seed.hashCode() % 360).absoluteValue.toFloat()
|
||||
return ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun ofManga(context: Context, manga: Manga?): Int {
|
||||
val color = if (manga != null) {
|
||||
val hue = (manga.id.absoluteValue % 360).toFloat()
|
||||
ColorUtils.HSLToColor(floatArrayOf(hue, 0.5f, 0.5f))
|
||||
} else {
|
||||
context.getThemeColor(R.attr.colorOutline)
|
||||
}
|
||||
val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh)
|
||||
return MaterialColors.harmonize(color, backgroundColor)
|
||||
}
|
||||
|
||||
private fun getHue(hex: String): Float {
|
||||
val r = (hex.substring(0, 2).toInt(16)).toFloat()
|
||||
val g = (hex.substring(2, 4).toInt(16)).toFloat()
|
||||
val b = (hex.substring(4, 6).toInt(16)).toFloat()
|
||||
|
||||
var hue = 0F
|
||||
if ((r >= g) && (g >= b)) {
|
||||
hue = 60 * (g - b) / (r - b)
|
||||
} else if ((g > r) && (r >= b)) {
|
||||
hue = 60 * (2 - (r - b) / (g - b))
|
||||
}
|
||||
return hue
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import android.content.ContextWrapper
|
||||
import android.content.OperationApplicationException
|
||||
import android.content.SharedPreferences
|
||||
import android.content.SyncResult
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.content.pm.ResolveInfo
|
||||
import android.database.SQLException
|
||||
import android.graphics.Bitmap
|
||||
@@ -27,14 +27,19 @@ import android.provider.Settings
|
||||
import android.view.View
|
||||
import android.view.ViewPropertyAnimator
|
||||
import android.view.Window
|
||||
import android.widget.Toast
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.annotation.IntegerRes
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.app.AppCompatDialog
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.coroutineScope
|
||||
import androidx.work.CoroutineWorker
|
||||
@@ -136,7 +141,7 @@ fun Window.setNavigationBarTransparentCompat(context: Context, elevation: Float,
|
||||
} else {
|
||||
// Set navbar scrim 70% of navigationBarColor
|
||||
ElevationOverlayProvider(context).compositeOverlayIfNeeded(
|
||||
context.getThemeColor(R.attr.m3ColorBottomMenuBackground, alphaFactor),
|
||||
context.getThemeColor(com.google.android.material.R.attr.colorSurfaceContainer, alphaFactor),
|
||||
elevation,
|
||||
)
|
||||
}
|
||||
@@ -216,25 +221,26 @@ fun Context.findActivity(): Activity? = when (this) {
|
||||
else -> null
|
||||
}
|
||||
|
||||
inline fun Activity.catchingWebViewUnavailability(block: () -> Unit): Boolean {
|
||||
return try {
|
||||
block()
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
if (e.isWebViewUnavailable()) {
|
||||
Toast.makeText(this, R.string.web_view_unavailable, Toast.LENGTH_LONG).show()
|
||||
finishAfterTransition()
|
||||
false
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
fun Fragment.findAppCompatDelegate(): AppCompatDelegate? {
|
||||
((this as? DialogFragment)?.dialog as? AppCompatDialog)?.run {
|
||||
return delegate
|
||||
}
|
||||
return parentFragment?.findAppCompatDelegate() ?: (activity as? AppCompatActivity)?.delegate
|
||||
}
|
||||
|
||||
fun Context.checkNotificationPermission(): Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||
} else {
|
||||
NotificationManagerCompat.from(this).areNotificationsEnabled()
|
||||
fun Context.checkNotificationPermission(channelId: String?): Boolean {
|
||||
val hasPermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PERMISSION_GRANTED
|
||||
} else {
|
||||
NotificationManagerCompat.from(this).areNotificationsEnabled()
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && hasPermission && channelId != null) {
|
||||
val channel = NotificationManagerCompat.from(this).getNotificationChannel(channelId)
|
||||
if (channel != null && channel.importance == NotificationManagerCompat.IMPORTANCE_NONE) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return hasPermission
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@@ -251,3 +257,13 @@ fun Context.ensureRamAtLeast(requiredSize: Long) {
|
||||
throw IllegalStateException("Not enough free memory")
|
||||
}
|
||||
}
|
||||
|
||||
fun WebView.configureForParser(userAgentOverride: String?) = with(settings) {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
mediaPlaybackRequiresUserGesture = false
|
||||
databaseEnabled = true
|
||||
if (userAgentOverride != null) {
|
||||
userAgentString = userAgentOverride
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,15 @@ inline fun <reified T : Parcelable> Intent.getParcelableExtraCompat(key: String)
|
||||
}
|
||||
|
||||
inline fun <reified T : Serializable> Intent.getSerializableExtraCompat(key: String): T? {
|
||||
return getSerializableExtra(key) as T?
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
getSerializableExtra(key, T::class.java)
|
||||
} else {
|
||||
getSerializableExtra(key) as T?
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : Serializable> Bundle.getSerializableCompat(key: String): T? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
getSerializable(key, T::class.java)
|
||||
} else {
|
||||
getSerializable(key) as T?
|
||||
|
||||
@@ -25,7 +25,7 @@ fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): Image
|
||||
}
|
||||
// disposeImageRequest()
|
||||
return ImageRequest.Builder(context)
|
||||
.data(data?.takeUnless { it == "" })
|
||||
.data(data?.takeUnless { it == "" || it == 0 })
|
||||
.lifecycle(lifecycleOwner)
|
||||
.crossfade(context)
|
||||
.target(this)
|
||||
|
||||
@@ -18,6 +18,7 @@ fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo
|
||||
if (instant.until(Instant.now(), ChronoUnit.MINUTES) < 3) DateTimeAgo.JustNow
|
||||
else DateTimeAgo.Today
|
||||
}
|
||||
|
||||
diffDays == 1L -> DateTimeAgo.Yesterday
|
||||
diffDays < 6 -> DateTimeAgo.DaysAgo(diffDays.toInt())
|
||||
else -> {
|
||||
@@ -30,3 +31,5 @@ fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Long.toInstantOrNull() = if (this == 0L) null else Instant.ofEpochMilli(this)
|
||||
|
||||
@@ -10,7 +10,6 @@ import android.provider.OpenableColumns
|
||||
import androidx.core.database.getStringOrNull
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.fs.FileSequence
|
||||
import java.io.File
|
||||
@@ -19,6 +18,7 @@ import java.nio.file.attribute.BasicFileAttributes
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
import kotlin.io.path.ExperimentalPathApi
|
||||
import kotlin.io.path.PathWalkOption
|
||||
import kotlin.io.path.readAttributes
|
||||
import kotlin.io.path.walk
|
||||
|
||||
@@ -52,7 +52,7 @@ fun File.getStorageName(context: Context): String = runCatching {
|
||||
|
||||
fun Uri.toFileOrNull() = if (scheme == URI_SCHEME_FILE) path?.let(::File) else null
|
||||
|
||||
suspend fun File.deleteAwait() = withContext(Dispatchers.IO) {
|
||||
suspend fun File.deleteAwait() = runInterruptible(Dispatchers.IO) {
|
||||
delete() || deleteRecursively()
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ fun ContentResolver.resolveName(uri: Uri): String? {
|
||||
}
|
||||
|
||||
suspend fun File.computeSize(): Long = runInterruptible(Dispatchers.IO) {
|
||||
walkCompat().sumOf { it.length() }
|
||||
walkCompat(includeDirectories = false).sumOf { it.length() }
|
||||
}
|
||||
|
||||
fun File.children() = FileSequence(this)
|
||||
@@ -87,10 +87,16 @@ val File.creationTime
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPathApi::class)
|
||||
fun File.walkCompat() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
fun File.walkCompat(includeDirectories: Boolean): Sequence<File> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// Use lazy loading on Android 8.0 and later
|
||||
toPath().walk().map { it.toFile() }
|
||||
val walk = if (includeDirectories) {
|
||||
toPath().walk(PathWalkOption.INCLUDE_DIRECTORIES)
|
||||
} else {
|
||||
toPath().walk()
|
||||
}
|
||||
walk.map { it.toFile() }
|
||||
} else {
|
||||
// Directories are excluded by default in Path.walk(), so do it here as well
|
||||
walk().filter { it.isFile }
|
||||
val walk = walk()
|
||||
if (includeDirectories) walk else walk.filter { it.isFile }
|
||||
}
|
||||
|
||||
@@ -60,3 +60,25 @@ fun DialogFragment.showDistinct(fm: FragmentManager, tag: String) {
|
||||
}
|
||||
show(fm, tag)
|
||||
}
|
||||
|
||||
tailrec fun Fragment.dismissParentDialog(): Boolean {
|
||||
return when (val parent = parentFragment) {
|
||||
null -> return false
|
||||
is DialogFragment -> {
|
||||
parent.dismiss()
|
||||
true
|
||||
}
|
||||
|
||||
else -> parent.dismissParentDialog()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
tailrec fun <T> Fragment.findParentCallback(cls: Class<T>): T? {
|
||||
val parent = parentFragment
|
||||
return when {
|
||||
parent == null -> cls.castOrNull(activity)
|
||||
cls.isInstance(parent) -> parent as T
|
||||
else -> parent.findParentCallback(cls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.internal.closeQuietly
|
||||
import okhttp3.internal.isSensitiveHeader
|
||||
import okio.IOException
|
||||
import org.json.JSONObject
|
||||
import org.jsoup.HttpStatusException
|
||||
@@ -59,3 +61,16 @@ fun Cookie.newBuilder(): Cookie.Builder = Cookie.Builder().also { c ->
|
||||
c.httpOnly()
|
||||
}
|
||||
}
|
||||
|
||||
fun String.sanitizeHeaderValue(): String {
|
||||
return if (all(Char::isValidForHeaderValue)) {
|
||||
this // fast path
|
||||
} else {
|
||||
filter(Char::isValidForHeaderValue)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Char.isValidForHeaderValue(): Boolean {
|
||||
// from okhttp3.Headers$Companion.checkValue
|
||||
return this == '\t' || this in '\u0020'..'\u007e'
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
inline fun Int.ifZero(defaultValue: () -> Int): Int = if (this == 0) defaultValue() else this
|
||||
|
||||
inline fun Long.ifZero(defaultValue: () -> Long): Long = if (this == 0L) defaultValue() else this
|
||||
|
||||
fun longOf(a: Int, b: Int): Long {
|
||||
return a.toLong() shl 32 or (b.toLong() and 0xffffffffL)
|
||||
}
|
||||
|
||||
@@ -2,22 +2,23 @@ package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.res.Resources
|
||||
import android.util.AndroidRuntimeException
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.collection.arraySetOf
|
||||
import coil.network.HttpException
|
||||
import okio.FileNotFoundException
|
||||
import okio.IOException
|
||||
import org.acra.ktx.sendWithAcra
|
||||
import org.json.JSONException
|
||||
import org.jsoup.HttpStatusException
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
|
||||
import org.koitharu.kotatsu.core.exceptions.CaughtException
|
||||
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
|
||||
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
|
||||
import org.koitharu.kotatsu.core.exceptions.SyncApiException
|
||||
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
|
||||
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED
|
||||
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED
|
||||
@@ -43,6 +44,7 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
|
||||
|
||||
is TooManyRequestExceptions -> resources.getString(R.string.too_many_requests_message)
|
||||
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||
is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message)
|
||||
is FileNotFoundException -> resources.getString(R.string.file_not_found)
|
||||
is AccessDeniedException -> resources.getString(R.string.no_access_to_file)
|
||||
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||
@@ -55,8 +57,11 @@ fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
|
||||
is SocketTimeoutException,
|
||||
-> resources.getString(R.string.network_error)
|
||||
|
||||
is NoDataReceivedException -> resources.getString(R.string.error_no_data_received)
|
||||
|
||||
is WrongPasswordException -> resources.getString(R.string.wrong_password)
|
||||
is NotFoundException -> resources.getString(R.string.not_found_404)
|
||||
is UnsupportedSourceException -> resources.getString(R.string.unsupported_source)
|
||||
|
||||
is HttpException -> getHttpDisplayMessage(response.code, resources)
|
||||
is HttpStatusException -> getHttpDisplayMessage(statusCode, resources)
|
||||
@@ -99,24 +104,27 @@ fun Throwable.isReportable(): Boolean {
|
||||
return this is Error || this.javaClass in reportableExceptions
|
||||
}
|
||||
|
||||
fun Throwable.isNetworkError(): Boolean {
|
||||
return this is UnknownHostException || this is SocketTimeoutException
|
||||
}
|
||||
|
||||
fun Throwable.report() {
|
||||
val exception = CaughtException(this, "${javaClass.simpleName}($message)")
|
||||
exception.sendWithAcra()
|
||||
}
|
||||
|
||||
private val reportableExceptions = arraySetOf<Class<*>>(
|
||||
ParseException::class.java,
|
||||
JSONException::class.java,
|
||||
RuntimeException::class.java,
|
||||
IllegalStateException::class.java,
|
||||
IllegalArgumentException::class.java,
|
||||
ConcurrentModificationException::class.java,
|
||||
UnsupportedOperationException::class.java,
|
||||
NoDataReceivedException::class.java,
|
||||
)
|
||||
|
||||
fun Throwable.isWebViewUnavailable(): Boolean {
|
||||
return (this is AndroidRuntimeException && message?.contains("WebView") == true) ||
|
||||
cause?.isWebViewUnavailable() == true
|
||||
val trace = stackTraceToString()
|
||||
return trace.contains("android.webkit.WebView.<init>")
|
||||
}
|
||||
|
||||
@Suppress("FunctionName")
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.app.Activity
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import android.view.View.MeasureSpec
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Checkable
|
||||
import androidx.appcompat.widget.ActionMenuView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.view.SoftwareKeyboardControllerCompat
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.descendants
|
||||
import androidx.core.view.isVisible
|
||||
@@ -18,19 +17,18 @@ import androidx.recyclerview.widget.RecyclerView.ViewHolder
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.progressindicator.BaseProgressIndicator
|
||||
import com.google.android.material.slider.Slider
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
fun View.hideKeyboard() {
|
||||
val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(this.windowToken, 0)
|
||||
SoftwareKeyboardControllerCompat(this).hide()
|
||||
}
|
||||
|
||||
fun View.showKeyboard() {
|
||||
val imm = context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.showSoftInput(this, 0)
|
||||
SoftwareKeyboardControllerCompat(this).show()
|
||||
}
|
||||
|
||||
fun View.hasGlobalPoint(x: Int, y: Int): Boolean {
|
||||
@@ -172,3 +170,11 @@ fun MaterialButton.setProgressIcon() {
|
||||
icon = progressDrawable
|
||||
progressDrawable.start()
|
||||
}
|
||||
|
||||
fun Chip.setProgressIcon() {
|
||||
val progressDrawable = CircularProgressDrawable(context)
|
||||
progressDrawable.strokeWidth = resources.resolveDp(2f)
|
||||
progressDrawable.setColorSchemeColors(currentTextColor)
|
||||
chipIcon = progressDrawable
|
||||
progressDrawable.start()
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ data class ReadingTime(
|
||||
) {
|
||||
|
||||
fun format(resources: Resources): String = when {
|
||||
hours == 0 && minutes == 0 -> resources.getString(R.string.less_than_minute)
|
||||
hours == 0 -> resources.getQuantityString(R.plurals.minutes, minutes, minutes)
|
||||
minutes == 0 -> resources.getQuantityString(R.plurals.hours, hours, hours)
|
||||
else -> resources.getString(
|
||||
@@ -18,4 +19,11 @@ data class ReadingTime(
|
||||
resources.getQuantityString(R.plurals.minutes, minutes, minutes),
|
||||
)
|
||||
}
|
||||
|
||||
fun formatShort(resources: Resources): String? = when {
|
||||
hours == 0 && minutes == 0 -> null
|
||||
hours == 0 -> resources.getString(R.string.minutes_short, minutes)
|
||||
minutes == 0 -> resources.getString(R.string.hours_short, hours)
|
||||
else -> resources.getString(R.string.hours_minutes_short, hours, minutes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsFlow
|
||||
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||
@@ -35,6 +36,10 @@ class DetailsInteractor @Inject constructor(
|
||||
.map { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun observeFavourite(mangaId: Long): Flow<Set<FavouriteCategory>> {
|
||||
return favouritesRepository.observeCategories(mangaId)
|
||||
}
|
||||
|
||||
fun observeNewChapters(mangaId: Long): Flow<Int> {
|
||||
return settings.observeAsFlow(AppSettings.KEY_TRACKER_ENABLED) { isTrackerEnabled }
|
||||
.flatMapLatest { isEnabled ->
|
||||
|
||||
@@ -54,7 +54,8 @@ class DetailsLoadUseCase @Inject constructor(
|
||||
} catch (e: IOException) {
|
||||
local?.await()?.manga?.also { localManga ->
|
||||
send(MangaDetails(localManga, null, localManga.description?.parseAsHtml(withImages = false), true))
|
||||
} ?: throw e
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,25 +5,27 @@ import org.koitharu.kotatsu.core.model.findById
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||
import org.koitharu.kotatsu.details.data.ReadingTime
|
||||
import org.koitharu.kotatsu.stats.data.StatsRepository
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class ReadingTimeUseCase @Inject constructor(
|
||||
private val settings: AppSettings,
|
||||
private val statsRepository: StatsRepository,
|
||||
) {
|
||||
|
||||
fun invoke(manga: MangaDetails?, branch: String?, history: MangaHistory?): ReadingTime? {
|
||||
suspend fun invoke(manga: MangaDetails?, branch: String?, history: MangaHistory?): ReadingTime? {
|
||||
if (!settings.isReadingTimeEstimationEnabled) {
|
||||
return null
|
||||
}
|
||||
// FIXME MAXIMUM HARDCODE!!! To do calculation with user's page read speed and his favourites/history mangas average pages in chapter
|
||||
val chapters = manga?.chapters?.get(branch)
|
||||
if (chapters.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val isOnHistoryBranch = history != null && chapters.findById(history.chapterId) != null
|
||||
// Impossible task, I guess. Good luck on this.
|
||||
var averageTimeSec: Int = 20 * 10 * chapters.size // 20 pages, 10 seconds per page
|
||||
var averageTimeSec: Int = 20 /* pages */ * getSecondsPerPage(manga.id) * chapters.size
|
||||
if (isOnHistoryBranch) {
|
||||
averageTimeSec = (averageTimeSec * (1f - checkNotNull(history).percent)).roundToInt()
|
||||
}
|
||||
@@ -36,4 +38,16 @@ class ReadingTimeUseCase @Inject constructor(
|
||||
isContinue = isOnHistoryBranch,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun getSecondsPerPage(mangaId: Long): Int {
|
||||
var time = if (settings.isStatsEnabled) {
|
||||
TimeUnit.MILLISECONDS.toSeconds(statsRepository.getTimePerPage(mangaId)).toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
if (time == 0) {
|
||||
time = 10 // default
|
||||
}
|
||||
return time
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.slider.LabelFormatter
|
||||
import com.google.android.material.slider.Slider
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
import org.koitharu.kotatsu.core.util.ext.setValueRounded
|
||||
import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter
|
||||
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_CHAPTERS
|
||||
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_PAGES
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
class ChapterPagesMenuProvider(
|
||||
private val viewModel: DetailsViewModel,
|
||||
private val sheet: BaseAdaptiveSheet<*>,
|
||||
private val pager: ViewPager2,
|
||||
private val settings: AppSettings,
|
||||
) : OnBackPressedCallback(false), MenuProvider, SearchView.OnQueryTextListener, MenuItem.OnActionExpandListener,
|
||||
Slider.OnChangeListener {
|
||||
|
||||
private var expandedItemRef: WeakReference<MenuItem>? = null
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
val tab = getCurrentTab()
|
||||
when (tab) {
|
||||
TAB_CHAPTERS -> {
|
||||
menuInflater.inflate(R.menu.opt_chapters, menu)
|
||||
menu.findItem(R.id.action_search)?.run {
|
||||
setOnActionExpandListener(this@ChapterPagesMenuProvider)
|
||||
(actionView as? SearchView)?.setupChaptersSearchView()
|
||||
}
|
||||
menu.findItem(R.id.action_search)?.isVisible = viewModel.isChaptersEmpty.value == false
|
||||
menu.findItem(R.id.action_reversed)?.isChecked = viewModel.isChaptersReversed.value == true
|
||||
menu.findItem(R.id.action_grid_view)?.isChecked = viewModel.isChaptersInGridView.value == true
|
||||
}
|
||||
|
||||
TAB_PAGES -> {
|
||||
menuInflater.inflate(R.menu.opt_pages, menu)
|
||||
menu.findItem(R.id.action_grid_size)?.run {
|
||||
setOnActionExpandListener(this@ChapterPagesMenuProvider)
|
||||
(actionView as? Slider)?.setupPagesSizeSlider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
R.id.action_reversed -> {
|
||||
viewModel.setChaptersReversed(!menuItem.isChecked)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_grid_view -> {
|
||||
viewModel.setChaptersInGridView(!menuItem.isChecked)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
override fun handleOnBackPressed() {
|
||||
expandedItemRef?.get()?.collapseActionView()
|
||||
}
|
||||
|
||||
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
|
||||
expandedItemRef = WeakReference(item)
|
||||
sheet.expandAndLock()
|
||||
isEnabled = true
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
|
||||
expandedItemRef = null
|
||||
isEnabled = false
|
||||
(item.actionView as? SearchView)?.setQuery("", false)
|
||||
viewModel.performChapterSearch(null)
|
||||
sheet.unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onQueryTextSubmit(query: String?): Boolean = false
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
viewModel.performChapterSearch(newText)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) {
|
||||
if (fromUser) {
|
||||
settings.gridSizePages = value.toInt()
|
||||
}
|
||||
}
|
||||
|
||||
private fun SearchView.setupChaptersSearchView() {
|
||||
setOnQueryTextListener(this@ChapterPagesMenuProvider)
|
||||
setIconifiedByDefault(false)
|
||||
queryHint = context.getString(R.string.search_chapters)
|
||||
}
|
||||
|
||||
private fun Slider.setupPagesSizeSlider() {
|
||||
valueFrom = 50f
|
||||
valueTo = 150f
|
||||
stepSize = 5f
|
||||
isTickVisible = false
|
||||
labelBehavior = LabelFormatter.LABEL_FLOATING
|
||||
setLabelFormatter(IntPercentLabelFormatter(context))
|
||||
setValueRounded(settings.gridSizePages.toFloat())
|
||||
addOnChangeListener(this@ChapterPagesMenuProvider)
|
||||
}
|
||||
|
||||
private fun getCurrentTab(): Int {
|
||||
var page = pager.currentItem
|
||||
if (page > 0 && pager.adapter?.itemCount == 2) { // no Pages page
|
||||
page++ // shift
|
||||
}
|
||||
return page
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ fun MangaDetails.mapChapters(
|
||||
newCount: Int,
|
||||
branch: String?,
|
||||
bookmarks: List<Bookmark>,
|
||||
isGrid: Boolean,
|
||||
): List<ChapterListItem> {
|
||||
val remoteChapters = chapters[branch].orEmpty()
|
||||
val localChapters = local?.manga?.getChapters(branch).orEmpty()
|
||||
@@ -47,6 +48,7 @@ fun MangaDetails.mapChapters(
|
||||
isNew = isUnread && result.size >= newFrom,
|
||||
isDownloaded = local != null,
|
||||
isBookmarked = chapter.id in bookmarked,
|
||||
isGrid = isGrid,
|
||||
)
|
||||
}
|
||||
if (!localMap.isNullOrEmpty()) {
|
||||
@@ -60,6 +62,7 @@ fun MangaDetails.mapChapters(
|
||||
isNew = false,
|
||||
isDownloaded = !isLocal,
|
||||
isBookmarked = chapter.id in bookmarked,
|
||||
isGrid = isGrid,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,9 @@ class ChaptersMenuProvider(
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
menu.findItem(R.id.action_reversed)?.isChecked = viewModel.isChaptersReversed.value == true
|
||||
menu.findItem(R.id.action_search)?.isVisible = viewModel.isChaptersEmpty.value == false
|
||||
menu.findItem(R.id.action_reversed)?.isChecked = viewModel.isChaptersReversed.value == true
|
||||
menu.findItem(R.id.action_grid_view)?.isChecked = viewModel.isChaptersInGridView.value == true
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) {
|
||||
@@ -37,6 +38,10 @@ class ChaptersMenuProvider(
|
||||
viewModel.setChaptersReversed(!menuItem.isChecked)
|
||||
true
|
||||
}
|
||||
R.id.action_grid_view-> {
|
||||
viewModel.setChaptersInGridView(!menuItem.isChecked)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
@@ -36,7 +37,6 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
@@ -125,20 +125,11 @@ class DetailsActivity :
|
||||
viewModel.manga.filterNotNull().observe(this, ::onMangaUpdated)
|
||||
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
|
||||
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
|
||||
viewModel.onError.observeEvent(
|
||||
viewModel.onError.observeEvent(this, DetailsErrorObserver(this, viewModel, exceptionResolver))
|
||||
viewModel.onActionDone.observeEvent(
|
||||
this,
|
||||
SnackbarErrorObserver(
|
||||
host = viewBinding.containerDetails,
|
||||
fragment = null,
|
||||
resolver = exceptionResolver,
|
||||
onResolved = { isResolved ->
|
||||
if (isResolved) {
|
||||
viewModel.reload()
|
||||
}
|
||||
},
|
||||
),
|
||||
ReversibleActionObserver(viewBinding.containerDetails, viewBinding.layoutBottom),
|
||||
)
|
||||
viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.containerDetails))
|
||||
viewModel.onShowTip.observeEvent(this) { showTip() }
|
||||
viewModel.historyInfo.observe(this, ::onHistoryChanged)
|
||||
viewModel.selectedBranch.observe(this) {
|
||||
@@ -150,6 +141,7 @@ class DetailsActivity :
|
||||
viewModel.isChaptersEmpty.observe(this, chaptersMenuInvalidator)
|
||||
val menuInvalidator = MenuInvalidator(this)
|
||||
viewModel.favouriteCategories.observe(this, menuInvalidator)
|
||||
viewModel.isStatsAvailable.observe(this, menuInvalidator)
|
||||
viewModel.remoteManga.observe(this, menuInvalidator)
|
||||
viewModel.branches.observe(this) {
|
||||
viewBinding.buttonDropdown.isVisible = it.size > 1
|
||||
@@ -187,6 +179,9 @@ class DetailsActivity :
|
||||
buttonTip = null
|
||||
val menu = PopupMenu(v.context, v)
|
||||
menu.inflate(R.menu.popup_read)
|
||||
menu.menu.findItem(R.id.action_forget)?.isVisible = viewModel.historyInfo.value.run {
|
||||
!isIncognitoMode && history != null
|
||||
}
|
||||
menu.setOnMenuItemClickListener(this)
|
||||
menu.setForceShowIcon(true)
|
||||
menu.show()
|
||||
@@ -203,6 +198,11 @@ class DetailsActivity :
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_forget -> {
|
||||
viewModel.removeFromHistory()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_pages_thumbs -> {
|
||||
val history = viewModel.historyInfo.value.history
|
||||
PagesThumbnailsSheet.show(
|
||||
@@ -377,12 +377,13 @@ class DetailsActivity :
|
||||
}
|
||||
|
||||
private fun initPager() {
|
||||
val adapter = DetailsPagerAdapter(this)
|
||||
val adapter = DetailsPagerAdapter(this, settings)
|
||||
viewBinding.pager.recyclerView?.isNestedScrollingEnabled = false
|
||||
viewBinding.pager.offscreenPageLimit = 1
|
||||
viewBinding.pager.adapter = adapter
|
||||
TabLayoutMediator(viewBinding.tabs, viewBinding.pager, adapter).attach()
|
||||
viewBinding.pager.setCurrentItem(settings.defaultDetailsTab, false)
|
||||
viewBinding.tabs.isVisible = adapter.itemCount > 1
|
||||
}
|
||||
|
||||
private fun showBottomSheet(isVisible: Boolean) {
|
||||
@@ -422,15 +423,25 @@ class DetailsActivity :
|
||||
companion object {
|
||||
|
||||
const val TIP_BUTTON = "btn_read"
|
||||
private const val KEY_NEW_ACTIVITY = "new_details_screen"
|
||||
|
||||
fun newIntent(context: Context, manga: Manga): Intent {
|
||||
return Intent(context, DetailsActivity::class.java)
|
||||
return getActivityIntent(context)
|
||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
||||
}
|
||||
|
||||
fun newIntent(context: Context, mangaId: Long): Intent {
|
||||
return Intent(context, DetailsActivity::class.java)
|
||||
return getActivityIntent(context)
|
||||
.putExtra(MangaIntent.KEY_ID, mangaId)
|
||||
}
|
||||
|
||||
private fun getActivityIntent(context: Context): Intent {
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val useNewActivity = prefs.getBoolean(KEY_NEW_ACTIVITY, false)
|
||||
return Intent(
|
||||
context,
|
||||
if (useNewActivity) DetailsActivity2::class.java else DetailsActivity::class.java,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,703 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.text.style.DynamicDrawableSpan
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.ImageSpan
|
||||
import android.text.style.RelativeSizeSpan
|
||||
import android.transition.TransitionManager
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.text.buildSpannedString
|
||||
import androidx.core.text.inSpans
|
||||
import androidx.core.text.method.LinkMovementMethodCompat
|
||||
import androidx.core.view.isGone
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.SuccessResult
|
||||
import coil.transform.CircleCropTransformation
|
||||
import coil.util.CoilUtils
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.model.iconResId
|
||||
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
|
||||
import org.koitharu.kotatsu.core.model.titleResId
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.parser.MangaIntent
|
||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||
import org.koitharu.kotatsu.core.ui.BaseActivity
|
||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
||||
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration
|
||||
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
|
||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||
import org.koitharu.kotatsu.core.util.FileSize
|
||||
import org.koitharu.kotatsu.core.util.ViewBadge
|
||||
import org.koitharu.kotatsu.core.util.ext.crossfade
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||
import org.koitharu.kotatsu.core.util.ext.isTextTruncated
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.parentView
|
||||
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
|
||||
import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat
|
||||
import org.koitharu.kotatsu.core.util.ext.source
|
||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ActivityDetailsNewBinding
|
||||
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||
import org.koitharu.kotatsu.details.data.ReadingTime
|
||||
import org.koitharu.kotatsu.details.service.MangaPrefetchService
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.details.ui.model.HistoryInfo
|
||||
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet
|
||||
import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity
|
||||
import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration
|
||||
import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter
|
||||
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
|
||||
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
|
||||
import org.koitharu.kotatsu.image.ui.ImageActivity
|
||||
import org.koitharu.kotatsu.list.domain.ListExtraProvider
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
|
||||
import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver
|
||||
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.parsers.util.ellipsize
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
|
||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class DetailsActivity2 :
|
||||
BaseActivity<ActivityDetailsNewBinding>(),
|
||||
View.OnClickListener,
|
||||
View.OnLongClickListener, PopupMenu.OnMenuItemClickListener, View.OnLayoutChangeListener,
|
||||
ViewTreeObserver.OnDrawListener, ChipsView.OnChipClickListener, OnListItemClickListener<Bookmark> {
|
||||
|
||||
@Inject
|
||||
lateinit var shortcutManager: AppShortcutManager
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
@Inject
|
||||
lateinit var tagHighlighter: ListExtraProvider
|
||||
|
||||
private val viewModel: DetailsViewModel by viewModels()
|
||||
|
||||
var bottomSheetMediator: ChaptersBottomSheetMediator? = null
|
||||
private set
|
||||
|
||||
private lateinit var chaptersBadge: ViewBadge
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(ActivityDetailsNewBinding.inflate(layoutInflater))
|
||||
supportActionBar?.run {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setDisplayShowTitleEnabled(false)
|
||||
}
|
||||
viewBinding.buttonRead.setOnClickListener(this)
|
||||
viewBinding.buttonRead.setOnLongClickListener(this)
|
||||
viewBinding.buttonRead.setOnContextClickListenerCompat(this)
|
||||
viewBinding.buttonChapters.setOnClickListener(this)
|
||||
viewBinding.infoLayout.chipBranch.setOnClickListener(this)
|
||||
viewBinding.infoLayout.chipSize.setOnClickListener(this)
|
||||
viewBinding.infoLayout.chipSource.setOnClickListener(this)
|
||||
viewBinding.infoLayout.chipFavorite.setOnClickListener(this)
|
||||
viewBinding.infoLayout.chipAuthor.setOnClickListener(this)
|
||||
viewBinding.infoLayout.chipTime.setOnClickListener(this)
|
||||
viewBinding.imageViewCover.setOnClickListener(this)
|
||||
viewBinding.buttonDescriptionMore.setOnClickListener(this)
|
||||
viewBinding.buttonScrobblingMore.setOnClickListener(this)
|
||||
viewBinding.buttonRelatedMore.setOnClickListener(this)
|
||||
viewBinding.infoLayout.chipSource.setOnClickListener(this)
|
||||
viewBinding.infoLayout.chipSize.setOnClickListener(this)
|
||||
viewBinding.textViewDescription.addOnLayoutChangeListener(this)
|
||||
viewBinding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
|
||||
viewBinding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
|
||||
viewBinding.chipsTags.onChipClickListener = this
|
||||
viewBinding.recyclerViewRelated.addItemDecoration(
|
||||
SpacingItemDecoration(resources.getDimensionPixelOffset(R.dimen.grid_spacing)),
|
||||
)
|
||||
TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView)
|
||||
|
||||
chaptersBadge = ViewBadge(viewBinding.buttonChapters, this)
|
||||
|
||||
viewModel.details.filterNotNull().observe(this, ::onMangaUpdated)
|
||||
viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved)
|
||||
viewModel.newChaptersCount.observe(this, ::onNewChaptersChanged)
|
||||
viewModel.onError.observeEvent(
|
||||
this,
|
||||
SnackbarErrorObserver(viewBinding.scrollView, null, exceptionResolver) {
|
||||
if (it) viewModel.reload()
|
||||
},
|
||||
)
|
||||
viewModel.onActionDone.observeEvent(this, ReversibleActionObserver(viewBinding.scrollView, null))
|
||||
viewModel.historyInfo.observe(this, ::onHistoryChanged)
|
||||
viewModel.isLoading.observe(this, ::onLoadingStateChanged)
|
||||
viewModel.scrobblingInfo.observe(this, ::onScrobblingInfoChanged)
|
||||
viewModel.localSize.observe(this, ::onLocalSizeChanged)
|
||||
viewModel.relatedManga.observe(this, ::onRelatedMangaChanged)
|
||||
// viewModel.chapters.observe(this, ::onChaptersChanged)
|
||||
viewModel.readingTime.observe(this, ::onReadingTimeChanged)
|
||||
viewModel.selectedBranch.observe(this) {
|
||||
viewBinding.infoLayout.chipBranch.text = it.ifNullOrEmpty { getString(R.string.system_default) }
|
||||
}
|
||||
viewModel.favouriteCategories.observe(this, ::onFavoritesChanged)
|
||||
val menuInvalidator = MenuInvalidator(this)
|
||||
viewModel.isStatsAvailable.observe(this, menuInvalidator)
|
||||
viewModel.remoteManga.observe(this, menuInvalidator)
|
||||
viewModel.branches.observe(this) {
|
||||
viewBinding.infoLayout.chipBranch.isVisible = it.size > 1
|
||||
}
|
||||
viewModel.chapters.observe(this, PrefetchObserver(this))
|
||||
viewModel.onDownloadStarted.observeEvent(
|
||||
this,
|
||||
DownloadStartedObserver(viewBinding.scrollView),
|
||||
)
|
||||
|
||||
addMenuProvider(
|
||||
DetailsMenuProvider(
|
||||
activity = this,
|
||||
viewModel = viewModel,
|
||||
snackbarHost = viewBinding.scrollView,
|
||||
appShortcutManager = shortcutManager,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
when (v.id) {
|
||||
R.id.button_read -> openReader(isIncognitoMode = false)
|
||||
R.id.chip_branch -> showBranchPopupMenu(v)
|
||||
R.id.button_chapters -> {
|
||||
ChaptersPagesSheet.show(supportFragmentManager)
|
||||
}
|
||||
|
||||
R.id.chip_author -> {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
startActivity(
|
||||
SearchActivity.newIntent(
|
||||
context = v.context,
|
||||
source = manga.source,
|
||||
query = manga.author ?: return,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
R.id.chip_source -> {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
startActivity(
|
||||
MangaListActivity.newIntent(
|
||||
context = v.context,
|
||||
source = manga.source,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
R.id.chip_size -> {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
LocalInfoDialog.show(supportFragmentManager, manga)
|
||||
}
|
||||
|
||||
R.id.chip_favorite -> {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
FavoriteSheet.show(supportFragmentManager, manga)
|
||||
}
|
||||
|
||||
R.id.chip_time -> {
|
||||
if (viewModel.isStatsAvailable.value) {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
MangaStatsSheet.show(supportFragmentManager, manga)
|
||||
} else {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
R.id.imageView_cover -> {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
startActivity(
|
||||
ImageActivity.newIntent(
|
||||
v.context,
|
||||
manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl },
|
||||
manga.source,
|
||||
),
|
||||
scaleUpActivityOptionsOf(v),
|
||||
)
|
||||
}
|
||||
|
||||
R.id.button_description_more -> {
|
||||
val tv = viewBinding.textViewDescription
|
||||
TransitionManager.beginDelayedTransition(tv.parentView)
|
||||
if (tv.maxLines in 1 until Integer.MAX_VALUE) {
|
||||
tv.maxLines = Integer.MAX_VALUE
|
||||
} else {
|
||||
tv.maxLines = resources.getInteger(R.integer.details_description_lines)
|
||||
}
|
||||
}
|
||||
|
||||
R.id.button_scrobbling_more -> {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
ScrobblingSelectorSheet.show(supportFragmentManager, manga, null)
|
||||
}
|
||||
|
||||
R.id.button_related_more -> {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
startActivity(RelatedMangaActivity.newIntent(v.context, manga))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onChipClick(chip: Chip, data: Any?) {
|
||||
val tag = data as? MangaTag ?: return
|
||||
startActivity(MangaListActivity.newIntent(this, setOf(tag)))
|
||||
}
|
||||
|
||||
override fun onLongClick(v: View): Boolean = when (v.id) {
|
||||
R.id.button_read -> {
|
||||
val menu = PopupMenu(v.context, v)
|
||||
menu.inflate(R.menu.popup_read)
|
||||
menu.menu.findItem(R.id.action_forget)?.isVisible = viewModel.historyInfo.value.run {
|
||||
!isIncognitoMode && history != null
|
||||
}
|
||||
menu.setOnMenuItemClickListener(this)
|
||||
menu.setForceShowIcon(true)
|
||||
menu.show()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.action_incognito -> {
|
||||
openReader(isIncognitoMode = true)
|
||||
true
|
||||
}
|
||||
|
||||
R.id.action_forget -> {
|
||||
viewModel.removeFromHistory()
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemClick(item: Bookmark, view: View) {
|
||||
startActivity(
|
||||
IntentBuilder(view.context).bookmark(item).incognito(true).build(),
|
||||
)
|
||||
Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onDraw() {
|
||||
viewBinding.run {
|
||||
buttonDescriptionMore.isVisible = textViewDescription.maxLines == Int.MAX_VALUE ||
|
||||
textViewDescription.isTextTruncated
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLayoutChange(
|
||||
v: View?,
|
||||
left: Int,
|
||||
top: Int,
|
||||
right: Int,
|
||||
bottom: Int,
|
||||
oldLeft: Int,
|
||||
oldTop: Int,
|
||||
oldRight: Int,
|
||||
oldBottom: Int
|
||||
) {
|
||||
with(viewBinding) {
|
||||
buttonDescriptionMore.isVisible = textViewDescription.isTextTruncated
|
||||
}
|
||||
}
|
||||
|
||||
private fun onChaptersChanged(chapters: List<ChapterListItem>?) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
private fun onFavoritesChanged(categories: Set<FavouriteCategory>) {
|
||||
val chip = viewBinding.infoLayout.chipFavorite
|
||||
chip.setChipIconResource(if (categories.isEmpty()) R.drawable.ic_heart_outline else R.drawable.ic_heart)
|
||||
chip.text = if (categories.isEmpty()) {
|
||||
getString(R.string.add_to_favourites)
|
||||
} else {
|
||||
if (categories.size == 1) {
|
||||
categories.first().title.ellipsize(FAV_LABEL_LIMIT)
|
||||
}
|
||||
buildString(FAV_LABEL_LIMIT + 6) {
|
||||
for ((i, cat) in categories.withIndex()) {
|
||||
if (i == 0) {
|
||||
append(cat.title.ellipsize(FAV_LABEL_LIMIT - 4))
|
||||
} else if (length + cat.title.length > FAV_LABEL_LIMIT) {
|
||||
append(", ")
|
||||
append(getString(R.string.list_ellipsize_pattern, categories.size - i))
|
||||
break
|
||||
} else {
|
||||
append(", ")
|
||||
append(cat.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onReadingTimeChanged(time: ReadingTime?) {
|
||||
val chip = viewBinding.infoLayout.chipTime
|
||||
chip.textAndVisible = time?.formatShort(chip.resources)
|
||||
}
|
||||
|
||||
private fun onDescriptionChanged(description: CharSequence?) {
|
||||
val tv = viewBinding.textViewDescription
|
||||
if (description.isNullOrBlank()) {
|
||||
tv.setText(R.string.no_description)
|
||||
} else {
|
||||
tv.text = description
|
||||
}
|
||||
}
|
||||
|
||||
private fun onLocalSizeChanged(size: Long) {
|
||||
val chip = viewBinding.infoLayout.chipSize
|
||||
if (size == 0L) {
|
||||
chip.isVisible = false
|
||||
} else {
|
||||
chip.text = FileSize.BYTES.format(chip.context, size)
|
||||
chip.isVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRelatedMangaChanged(related: List<MangaItemModel>) {
|
||||
if (related.isEmpty()) {
|
||||
viewBinding.groupRelated.isVisible = false
|
||||
return
|
||||
}
|
||||
val rv = viewBinding.recyclerViewRelated
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val adapter = (rv.adapter as? BaseListAdapter<ListModel>) ?: BaseListAdapter<ListModel>()
|
||||
.addDelegate(
|
||||
ListItemType.MANGA_GRID,
|
||||
mangaGridItemAD(
|
||||
coil, this,
|
||||
StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)),
|
||||
) { item, view ->
|
||||
startActivity(DetailsActivity.newIntent(view.context, item))
|
||||
},
|
||||
).also { rv.adapter = it }
|
||||
adapter.items = related
|
||||
viewBinding.groupRelated.isVisible = true
|
||||
}
|
||||
|
||||
private fun onLoadingStateChanged(isLoading: Boolean) {
|
||||
val button = viewBinding.buttonChapters
|
||||
if (isLoading) {
|
||||
button.setImageDrawable(
|
||||
CircularProgressDrawable(this).also {
|
||||
it.setStyle(CircularProgressDrawable.LARGE)
|
||||
it.setColorSchemeColors(getThemeColor(materialR.attr.colorControlNormal))
|
||||
it.start()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
button.setImageResource(R.drawable.ic_list_sheet)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onScrobblingInfoChanged(scrobblings: List<ScrobblingInfo>) {
|
||||
var adapter = viewBinding.recyclerViewScrobbling.adapter as? ScrollingInfoAdapter
|
||||
viewBinding.groupScrobbling.isGone = scrobblings.isEmpty()
|
||||
if (adapter != null) {
|
||||
adapter.items = scrobblings
|
||||
} else {
|
||||
adapter = ScrollingInfoAdapter(this, coil, supportFragmentManager)
|
||||
adapter.items = scrobblings
|
||||
viewBinding.recyclerViewScrobbling.adapter = adapter
|
||||
viewBinding.recyclerViewScrobbling.addItemDecoration(ScrobblingItemDecoration())
|
||||
}
|
||||
}
|
||||
|
||||
private fun onMangaUpdated(details: MangaDetails) {
|
||||
with(viewBinding) {
|
||||
val manga = details.toManga()
|
||||
val hasChapters = !manga.chapters.isNullOrEmpty()
|
||||
// Main
|
||||
loadCover(manga)
|
||||
textViewTitle.text = manga.title
|
||||
textViewSubtitle.textAndVisible = manga.altTitle
|
||||
infoLayout.chipAuthor.textAndVisible = manga.author
|
||||
if (manga.hasRating) {
|
||||
ratingBar.rating = manga.rating * ratingBar.numStars
|
||||
ratingBar.isVisible = true
|
||||
} else {
|
||||
ratingBar.isVisible = false
|
||||
}
|
||||
|
||||
manga.state?.let { state ->
|
||||
textViewState.textAndVisible = resources.getString(state.titleResId)
|
||||
imageViewState.setImageResource(state.iconResId)
|
||||
} ?: run {
|
||||
textViewState.isVisible = false
|
||||
imageViewState.isVisible = false
|
||||
}
|
||||
|
||||
if (manga.source == MangaSource.LOCAL || manga.source == MangaSource.DUMMY) {
|
||||
infoLayout.chipSource.isVisible = false
|
||||
} else {
|
||||
infoLayout.chipSource.text = manga.source.title
|
||||
infoLayout.chipSource.isVisible = true
|
||||
}
|
||||
|
||||
textViewNsfw.isVisible = manga.isNsfw
|
||||
|
||||
// Chips
|
||||
bindTags(manga)
|
||||
|
||||
textViewDescription.text = details.description.ifNullOrEmpty { getString(R.string.no_description) }
|
||||
|
||||
viewBinding.infoLayout.chipSource.also { chip ->
|
||||
ImageRequest.Builder(this@DetailsActivity2)
|
||||
.data(manga.source.faviconUri())
|
||||
.lifecycle(this@DetailsActivity2)
|
||||
.crossfade(false)
|
||||
.size(resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
|
||||
.target(ChipIconTarget(chip))
|
||||
.placeholder(R.drawable.ic_web)
|
||||
.fallback(R.drawable.ic_web)
|
||||
.error(R.drawable.ic_web)
|
||||
.source(manga.source)
|
||||
.transformations(CircleCropTransformation())
|
||||
.allowRgb565(true)
|
||||
.enqueueWith(coil)
|
||||
}
|
||||
|
||||
buttonChapters.isEnabled = hasChapters
|
||||
title = manga.title
|
||||
buttonRead.isEnabled = hasChapters
|
||||
invalidateOptionsMenu()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onMangaRemoved(manga: Manga) {
|
||||
Toast.makeText(
|
||||
this,
|
||||
getString(R.string._s_deleted_from_local_storage, manga.title),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
finishAfterTransition()
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) {
|
||||
viewBinding.root.updatePadding(
|
||||
left = insets.left,
|
||||
right = insets.right,
|
||||
bottom = insets.bottom
|
||||
)
|
||||
}
|
||||
|
||||
private fun onHistoryChanged(info: HistoryInfo) {
|
||||
with(viewBinding.buttonRead) {
|
||||
if (info.history != null) {
|
||||
setTitle(R.string._continue)
|
||||
} else {
|
||||
setTitle(R.string.read)
|
||||
}
|
||||
}
|
||||
viewBinding.buttonRead.subtitle = when {
|
||||
!info.isValid -> getString(R.string.loading_)
|
||||
info.currentChapter >= 0 -> getString(
|
||||
R.string.chapter_d_of_d,
|
||||
info.currentChapter + 1,
|
||||
info.totalChapters,
|
||||
)
|
||||
|
||||
info.totalChapters == 0 -> getString(R.string.no_chapters)
|
||||
else -> resources.getQuantityString(
|
||||
R.plurals.chapters,
|
||||
info.totalChapters,
|
||||
info.totalChapters,
|
||||
)
|
||||
}
|
||||
viewBinding.buttonRead.setProgress(info.history?.percent?.coerceIn(0f, 1f) ?: 0f, true)
|
||||
}
|
||||
|
||||
private fun onNewChaptersChanged(count: Int) {
|
||||
chaptersBadge.counter = count
|
||||
}
|
||||
|
||||
private fun showBranchPopupMenu(v: View) {
|
||||
val menu = PopupMenu(v.context, v)
|
||||
val branches = viewModel.branches.value
|
||||
for ((i, branch) in branches.withIndex()) {
|
||||
val title = buildSpannedString {
|
||||
if (branch.isCurrent) {
|
||||
inSpans(
|
||||
ImageSpan(
|
||||
this@DetailsActivity2,
|
||||
R.drawable.ic_current_chapter,
|
||||
DynamicDrawableSpan.ALIGN_BASELINE,
|
||||
),
|
||||
) {
|
||||
append(' ')
|
||||
}
|
||||
append(' ')
|
||||
}
|
||||
append(branch.name ?: getString(R.string.system_default))
|
||||
append(' ')
|
||||
append(' ')
|
||||
inSpans(
|
||||
ForegroundColorSpan(
|
||||
v.context.getThemeColor(
|
||||
android.R.attr.textColorSecondary,
|
||||
Color.LTGRAY,
|
||||
),
|
||||
),
|
||||
RelativeSizeSpan(0.74f),
|
||||
) {
|
||||
append(branch.count.toString())
|
||||
}
|
||||
}
|
||||
val item = menu.menu.add(R.id.group_branches, Menu.NONE, i, title)
|
||||
item.isCheckable = true
|
||||
item.isChecked = branch.isSelected
|
||||
}
|
||||
menu.menu.setGroupCheckable(R.id.group_branches, true, true)
|
||||
menu.setOnMenuItemClickListener {
|
||||
viewModel.setSelectedBranch(branches.getOrNull(it.order)?.name)
|
||||
true
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
|
||||
private fun openReader(isIncognitoMode: Boolean) {
|
||||
val manga = viewModel.manga.value ?: return
|
||||
val chapterId = viewModel.historyInfo.value.history?.chapterId
|
||||
if (chapterId != null && manga.chapters?.none { x -> x.id == chapterId } == true) {
|
||||
Snackbar.make(viewBinding.scrollView, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
|
||||
.show()
|
||||
} else {
|
||||
startActivity(
|
||||
IntentBuilder(this)
|
||||
.manga(manga)
|
||||
.branch(viewModel.selectedBranchValue)
|
||||
.incognito(isIncognitoMode)
|
||||
.build(),
|
||||
)
|
||||
if (isIncognitoMode) {
|
||||
Toast.makeText(this, R.string.incognito_mode, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindTags(manga: Manga) {
|
||||
viewBinding.chipsTags.isVisible = manga.tags.isNotEmpty()
|
||||
viewBinding.chipsTags.setChips(
|
||||
manga.tags.map { tag ->
|
||||
ChipsView.ChipModel(
|
||||
title = tag.title,
|
||||
tint = tagHighlighter.getTagTint(tag),
|
||||
icon = 0,
|
||||
data = tag,
|
||||
isCheckable = false,
|
||||
isChecked = false,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadCover(manga: Manga) {
|
||||
val imageUrl = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl }
|
||||
val lastResult = CoilUtils.result(viewBinding.imageViewCover)
|
||||
if (lastResult is SuccessResult && lastResult.request.data == imageUrl) {
|
||||
return
|
||||
}
|
||||
val request = ImageRequest.Builder(this)
|
||||
.target(viewBinding.imageViewCover)
|
||||
.size(CoverSizeResolver(viewBinding.imageViewCover))
|
||||
.data(imageUrl)
|
||||
.tag(manga.source)
|
||||
.crossfade(this)
|
||||
.lifecycle(this)
|
||||
.placeholderMemoryCacheKey(manga.coverUrl)
|
||||
val previousDrawable = lastResult?.drawable
|
||||
if (previousDrawable != null) {
|
||||
request.fallback(previousDrawable)
|
||||
.placeholder(previousDrawable)
|
||||
.error(previousDrawable)
|
||||
} else {
|
||||
request.fallback(R.drawable.ic_placeholder)
|
||||
.placeholder(R.drawable.ic_placeholder)
|
||||
.error(R.drawable.ic_error_placeholder)
|
||||
}
|
||||
request.enqueueWith(coil)
|
||||
}
|
||||
|
||||
private class PrefetchObserver(
|
||||
private val context: Context,
|
||||
) : FlowCollector<List<ChapterListItem>?> {
|
||||
|
||||
private var isCalled = false
|
||||
|
||||
override suspend fun emit(value: List<ChapterListItem>?) {
|
||||
if (value.isNullOrEmpty()) {
|
||||
return
|
||||
}
|
||||
if (!isCalled) {
|
||||
isCalled = true
|
||||
val item = value.find { it.isCurrent } ?: value.first()
|
||||
MangaPrefetchService.prefetchPages(context, item.chapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val FAV_LABEL_LIMIT = 10
|
||||
|
||||
fun newIntent(context: Context, manga: Manga): Intent {
|
||||
return Intent(context, DetailsActivity2::class.java)
|
||||
.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga))
|
||||
}
|
||||
|
||||
fun newIntent(context: Context, mangaId: Long): Intent {
|
||||
return Intent(context, DetailsActivity2::class.java)
|
||||
.putExtra(MangaIntent.KEY_ID, mangaId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package org.koitharu.kotatsu.details.ui
|
||||
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ErrorObserver
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||
import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.core.util.ext.isNetworkError
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.exception.ParseException
|
||||
|
||||
class DetailsErrorObserver(
|
||||
override val activity: DetailsActivity,
|
||||
private val viewModel: DetailsViewModel,
|
||||
resolver: ExceptionResolver?,
|
||||
) : ErrorObserver(
|
||||
activity.viewBinding.containerDetails, null, resolver,
|
||||
{ isResolved ->
|
||||
if (isResolved) {
|
||||
viewModel.reload()
|
||||
}
|
||||
},
|
||||
) {
|
||||
|
||||
override suspend fun emit(value: Throwable) {
|
||||
val snackbar = Snackbar.make(host, value.getDisplayMessage(host.context.resources), Snackbar.LENGTH_SHORT)
|
||||
if (value is NotFoundException || value is UnsupportedSourceException) {
|
||||
snackbar.duration = Snackbar.LENGTH_INDEFINITE
|
||||
}
|
||||
when {
|
||||
canResolve(value) -> {
|
||||
snackbar.setAction(ExceptionResolver.getResolveStringId(value)) {
|
||||
resolve(value)
|
||||
}
|
||||
}
|
||||
|
||||
value is ParseException -> {
|
||||
val fm = fragmentManager
|
||||
if (fm != null) {
|
||||
snackbar.setAction(R.string.details) {
|
||||
ErrorDetailsDialog.show(fm, value, value.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value.isNetworkError() -> {
|
||||
snackbar.setAction(R.string.try_again) {
|
||||
viewModel.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
snackbar.show()
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.MangaItemModel
|
||||
import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver
|
||||
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
|
||||
import org.koitharu.kotatsu.main.ui.owners.NoModalBottomSheetOwner
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
@@ -71,8 +72,6 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
|
||||
import org.koitharu.kotatsu.search.ui.MangaListActivity
|
||||
import org.koitharu.kotatsu.search.ui.SearchActivity
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -104,6 +103,7 @@ class DetailsFragment :
|
||||
binding.buttonScrobblingMore.setOnClickListener(this)
|
||||
binding.buttonRelatedMore.setOnClickListener(this)
|
||||
binding.infoLayout.textViewSource.setOnClickListener(this)
|
||||
binding.infoLayout.textViewSize.setOnClickListener(this)
|
||||
binding.textViewDescription.addOnLayoutChangeListener(this)
|
||||
binding.textViewDescription.viewTreeObserver.addOnDrawListener(this)
|
||||
binding.textViewDescription.movementMethod = LinkMovementMethodCompat.getInstance()
|
||||
@@ -189,7 +189,7 @@ class DetailsFragment :
|
||||
isVisible = false
|
||||
}
|
||||
}
|
||||
if (manga.source == MangaSource.LOCAL) {
|
||||
if (manga.source == MangaSource.LOCAL || manga.source == MangaSource.DUMMY) {
|
||||
infoLayout.textViewSource.isVisible = false
|
||||
} else {
|
||||
infoLayout.textViewSource.text = manga.source.title
|
||||
@@ -223,7 +223,7 @@ class DetailsFragment :
|
||||
}
|
||||
binding.approximateReadTime.text = time.format(resources)
|
||||
binding.approximateReadTimeTitle.setText(
|
||||
if (time.isContinue) R.string.approximate_remaining_time else R.string.approximate_reading_time
|
||||
if (time.isContinue) R.string.approximate_remaining_time else R.string.approximate_reading_time,
|
||||
)
|
||||
binding.approximateReadTimeLayout.isVisible = true
|
||||
}
|
||||
@@ -326,6 +326,10 @@ class DetailsFragment :
|
||||
)
|
||||
}
|
||||
|
||||
R.id.textView_size -> {
|
||||
LocalInfoDialog.show(parentFragmentManager, manga)
|
||||
}
|
||||
|
||||
R.id.imageView_cover -> {
|
||||
startActivity(
|
||||
ImageActivity.newIntent(
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity
|
||||
import org.koitharu.kotatsu.browser.BrowserActivity
|
||||
import org.koitharu.kotatsu.core.os.AppShortcutManager
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
@@ -23,6 +24,7 @@ import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
|
||||
import org.koitharu.kotatsu.search.ui.multi.MultiSearchActivity
|
||||
import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet
|
||||
|
||||
class DetailsMenuProvider(
|
||||
private val activity: FragmentActivity,
|
||||
@@ -33,6 +35,7 @@ class DetailsMenuProvider(
|
||||
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.opt_details, menu)
|
||||
menu.findItem(R.id.action_favourite).isVisible = activity is DetailsActivity
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
@@ -40,11 +43,13 @@ class DetailsMenuProvider(
|
||||
menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != MangaSource.LOCAL
|
||||
menu.findItem(R.id.action_delete).isVisible = manga?.source == MangaSource.LOCAL
|
||||
menu.findItem(R.id.action_browser).isVisible = manga?.source != MangaSource.LOCAL
|
||||
menu.findItem(R.id.action_alternatives).isVisible = manga?.source != MangaSource.LOCAL
|
||||
menu.findItem(R.id.action_shortcut).isVisible = ShortcutManagerCompat.isRequestPinShortcutSupported(activity)
|
||||
menu.findItem(R.id.action_scrobbling).isVisible = viewModel.isScrobblingAvailable
|
||||
menu.findItem(R.id.action_online).isVisible = viewModel.remoteManga.value != null
|
||||
menu.findItem(R.id.action_stats).isVisible = viewModel.isStatsAvailable.value
|
||||
menu.findItem(R.id.action_favourite).setIcon(
|
||||
if (viewModel.favouriteCategories.value) R.drawable.ic_heart else R.drawable.ic_heart_outline,
|
||||
if (viewModel.favouriteCategories.value.isNotEmpty()) R.drawable.ic_heart else R.drawable.ic_heart_outline,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -85,7 +90,7 @@ class DetailsMenuProvider(
|
||||
|
||||
R.id.action_browser -> {
|
||||
viewModel.manga.value?.let {
|
||||
activity.startActivity(BrowserActivity.newIntent(activity, it.publicUrl, it.title))
|
||||
activity.startActivity(BrowserActivity.newIntent(activity, it.publicUrl, it.source, it.title))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +106,18 @@ class DetailsMenuProvider(
|
||||
}
|
||||
}
|
||||
|
||||
R.id.action_alternatives -> {
|
||||
viewModel.manga.value?.let {
|
||||
activity.startActivity(AlternativesActivity.newIntent(activity, it))
|
||||
}
|
||||
}
|
||||
|
||||
R.id.action_stats -> {
|
||||
viewModel.manga.value?.let {
|
||||
MangaStatsSheet.show(activity.supportFragmentManager, it)
|
||||
}
|
||||
}
|
||||
|
||||
R.id.action_scrobbling -> {
|
||||
viewModel.manga.value?.let {
|
||||
ScrobblingSelectorSheet.show(activity.supportFragmentManager, it, null)
|
||||
|
||||
@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.plus
|
||||
@@ -35,6 +36,7 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||
import org.koitharu.kotatsu.core.util.ext.call
|
||||
import org.koitharu.kotatsu.core.util.ext.combine
|
||||
import org.koitharu.kotatsu.core.util.ext.computeSize
|
||||
import org.koitharu.kotatsu.core.util.ext.onEachWhile
|
||||
import org.koitharu.kotatsu.core.util.ext.requireValue
|
||||
@@ -61,6 +63,7 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
|
||||
import org.koitharu.kotatsu.stats.data.StatsRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -79,6 +82,7 @@ class DetailsViewModel @Inject constructor(
|
||||
private val detailsLoadUseCase: DetailsLoadUseCase,
|
||||
private val progressUpdateUseCase: ProgressUpdateUseCase,
|
||||
private val readingTimeUseCase: ReadingTimeUseCase,
|
||||
private val statsRepository: StatsRepository,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private val intent = MangaIntent(savedStateHandle)
|
||||
@@ -97,7 +101,10 @@ class DetailsViewModel @Inject constructor(
|
||||
val history = historyRepository.observeOne(mangaId)
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null)
|
||||
|
||||
val favouriteCategories = interactor.observeIsFavourite(mangaId)
|
||||
val favouriteCategories = interactor.observeFavourite(mangaId)
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
val isStatsAvailable = statsRepository.observeHasStats(mangaId)
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false)
|
||||
|
||||
val remoteManga = MutableStateFlow<Manga?>(null)
|
||||
@@ -116,7 +123,13 @@ class DetailsViewModel @Inject constructor(
|
||||
val isChaptersReversed = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_REVERSE_CHAPTERS,
|
||||
valueProducer = { chaptersReverse },
|
||||
valueProducer = { isChaptersReverse },
|
||||
)
|
||||
|
||||
val isChaptersInGridView = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_GRID_VIEW_CHAPTERS,
|
||||
valueProducer = { isChaptersGridView },
|
||||
)
|
||||
|
||||
val historyInfo: StateFlow<HistoryInfo> = combine(
|
||||
@@ -139,6 +152,7 @@ class DetailsViewModel @Inject constructor(
|
||||
val localSize = details
|
||||
.map { it?.local }
|
||||
.distinctUntilChanged()
|
||||
.combine(localStorageChanges.onStart { emit(null) }) { x, _ -> x }
|
||||
.map { local ->
|
||||
if (local != null) {
|
||||
runCatchingCancellable {
|
||||
@@ -200,12 +214,14 @@ class DetailsViewModel @Inject constructor(
|
||||
selectedBranch,
|
||||
newChaptersCount,
|
||||
bookmarks,
|
||||
) { manga, history, branch, news, bookmarks ->
|
||||
isChaptersInGridView,
|
||||
) { manga, history, branch, news, bookmarks, grid ->
|
||||
manga?.mapChapters(
|
||||
history,
|
||||
news,
|
||||
branch,
|
||||
bookmarks,
|
||||
grid,
|
||||
).orEmpty()
|
||||
},
|
||||
isChaptersReversed,
|
||||
@@ -275,7 +291,11 @@ class DetailsViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun setChaptersReversed(newValue: Boolean) {
|
||||
settings.chaptersReverse = newValue
|
||||
settings.isChaptersReverse = newValue
|
||||
}
|
||||
|
||||
fun setChaptersInGridView(newValue: Boolean) {
|
||||
settings.isChaptersGridView = newValue
|
||||
}
|
||||
|
||||
fun setSelectedBranch(branch: String?) {
|
||||
@@ -320,6 +340,7 @@ class DetailsViewModel @Inject constructor(
|
||||
page = 0,
|
||||
scroll = 0,
|
||||
percent = percent,
|
||||
force = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -346,6 +367,13 @@ class DetailsViewModel @Inject constructor(
|
||||
settings.closeTip(DetailsActivity.TIP_BUTTON)
|
||||
}
|
||||
|
||||
fun removeFromHistory() {
|
||||
launchJob(Dispatchers.Default) {
|
||||
val handle = historyRepository.delete(setOf(mangaId))
|
||||
onActionDone.call(ReversibleAction(R.string.removed_from_history, handle))
|
||||
}
|
||||
}
|
||||
|
||||
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
|
||||
detailsLoadUseCase.invoke(intent)
|
||||
.onEachWhile {
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.core.ui.dialog.RecyclerViewAlertDialog
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.download.ui.dialog.DownloadOption
|
||||
import org.koitharu.kotatsu.download.ui.dialog.downloadOptionAD
|
||||
import org.koitharu.kotatsu.settings.SettingsActivity
|
||||
|
||||
class DownloadDialogHelper(
|
||||
private val host: View,
|
||||
@@ -57,6 +58,9 @@ class DownloadDialogHelper(
|
||||
.setCancelable(true)
|
||||
.setTitle(R.string.download)
|
||||
.setNegativeButton(android.R.string.cancel)
|
||||
.setNeutralButton(R.string.settings) { _, _ ->
|
||||
host.context.startActivity(SettingsActivity.newDownloadsSettingsIntent(host.context))
|
||||
}
|
||||
.setItems(options)
|
||||
.create()
|
||||
.also { it.show() }
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.koitharu.kotatsu.details.ui.adapter
|
||||
|
||||
import android.graphics.Typeface
|
||||
import androidx.core.view.isVisible
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.core.model.formatNumber
|
||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
|
||||
import org.koitharu.kotatsu.databinding.ItemChapterGridBinding
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
fun chapterGridItemAD(
|
||||
clickListener: OnListItemClickListener<ChapterListItem>,
|
||||
) = adapterDelegateViewBinding<ChapterListItem, ListModel, ItemChapterGridBinding>(
|
||||
viewBinding = { inflater, parent -> ItemChapterGridBinding.inflate(inflater, parent, false) },
|
||||
on = { item, _, _ -> item is ChapterListItem && item.isGrid },
|
||||
) {
|
||||
|
||||
val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||
itemView.setOnClickListener(eventListener)
|
||||
itemView.setOnLongClickListener(eventListener)
|
||||
|
||||
bind { payloads ->
|
||||
if (payloads.isEmpty()) {
|
||||
binding.textViewTitle.text = item.chapter.formatNumber() ?: "?"
|
||||
}
|
||||
binding.imageViewNew.isVisible = item.isNew
|
||||
binding.imageViewCurrent.isVisible = item.isCurrent
|
||||
binding.imageViewBookmarked.isVisible = item.isBookmarked
|
||||
binding.imageViewDownloaded.isVisible = item.isDownloaded
|
||||
|
||||
when {
|
||||
item.isCurrent -> {
|
||||
binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary))
|
||||
binding.textViewTitle.typeface = Typeface.DEFAULT_BOLD
|
||||
}
|
||||
|
||||
item.isUnread -> {
|
||||
binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary))
|
||||
binding.textViewTitle.typeface = Typeface.DEFAULT
|
||||
}
|
||||
|
||||
else -> {
|
||||
binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorHint))
|
||||
binding.textViewTitle.typeface = Typeface.DEFAULT
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.isVisible
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.model.formatNumber
|
||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.drawableStart
|
||||
@@ -14,11 +13,13 @@ import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||
import org.koitharu.kotatsu.databinding.ItemChapterBinding
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import com.google.android.material.R as MR
|
||||
|
||||
fun chapterListItemAD(
|
||||
clickListener: OnListItemClickListener<ChapterListItem>,
|
||||
) = adapterDelegateViewBinding<ChapterListItem, ListModel, ItemChapterBinding>(
|
||||
{ inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) },
|
||||
viewBinding = { inflater, parent -> ItemChapterBinding.inflate(inflater, parent, false) },
|
||||
on = { item, _, _ -> item is ChapterListItem && !item.isGrid }
|
||||
) {
|
||||
|
||||
val eventListener = AdapterDelegateClickListenerAdapter(this, clickListener)
|
||||
@@ -46,7 +47,7 @@ fun chapterListItemAD(
|
||||
null
|
||||
}
|
||||
binding.textViewTitle.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary))
|
||||
binding.textViewDescription.setTextColor(context.getThemeColorStateList(android.R.attr.textColorPrimary))
|
||||
binding.textViewDescription.setTextColor(context.getThemeColorStateList(MR.attr.colorOutline))
|
||||
binding.textViewTitle.typeface = Typeface.DEFAULT
|
||||
binding.textViewDescription.typeface = Typeface.DEFAULT
|
||||
}
|
||||
|
||||
@@ -10,12 +10,13 @@ import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
|
||||
class ChaptersAdapter(
|
||||
onItemClickListener: OnListItemClickListener<ChapterListItem>,
|
||||
private val onItemClickListener: OnListItemClickListener<ChapterListItem>,
|
||||
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
||||
|
||||
init {
|
||||
addDelegate(ListItemType.CHAPTER, chapterListItemAD(onItemClickListener))
|
||||
addDelegate(ListItemType.HEADER, listHeaderAD(null))
|
||||
addDelegate(ListItemType.CHAPTER_LIST, chapterListItemAD(onItemClickListener))
|
||||
addDelegate(ListItemType.CHAPTER_GRID, chapterGridItemAD(onItemClickListener))
|
||||
}
|
||||
|
||||
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
||||
|
||||
@@ -6,16 +6,29 @@ import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.view.View
|
||||
import androidx.cardview.widget.CardView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
|
||||
import org.koitharu.kotatsu.core.util.ext.getItem
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecoration() {
|
||||
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val radius = context.resources.getDimension(materialR.dimen.abc_control_corner_material)
|
||||
private val checkIcon = ContextCompat.getDrawable(context, materialR.drawable.ic_mtrl_checked_circle)
|
||||
private val iconOffset = context.resources.getDimensionPixelOffset(R.dimen.chapter_check_offset)
|
||||
private val iconSize = context.resources.getDimensionPixelOffset(R.dimen.chapter_check_size)
|
||||
private val strokeColor = context.getThemeColor(materialR.attr.colorPrimary, Color.RED)
|
||||
private val fillColor = ColorUtils.setAlphaComponent(
|
||||
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
|
||||
0x74,
|
||||
)
|
||||
|
||||
init {
|
||||
paint.color = ColorUtils.setAlphaComponent(
|
||||
@@ -23,6 +36,18 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor
|
||||
98,
|
||||
)
|
||||
paint.style = Paint.Style.FILL
|
||||
hasBackground = true
|
||||
hasForeground = true
|
||||
isIncludeDecorAndMargins = false
|
||||
|
||||
paint.strokeWidth = context.resources.getDimension(R.dimen.selection_stroke_width)
|
||||
checkIcon?.setTint(strokeColor)
|
||||
}
|
||||
|
||||
override fun getItemId(parent: RecyclerView, child: View): Long {
|
||||
val holder = parent.getChildViewHolder(child) ?: return RecyclerView.NO_ID
|
||||
val item = holder.getItem(ChapterListItem::class.java) ?: return RecyclerView.NO_ID
|
||||
return item.chapter.id
|
||||
}
|
||||
|
||||
override fun onDrawBackground(
|
||||
@@ -32,6 +57,37 @@ class ChaptersSelectionDecoration(context: Context) : AbstractSelectionItemDecor
|
||||
bounds: RectF,
|
||||
state: RecyclerView.State,
|
||||
) {
|
||||
if (child is CardView) {
|
||||
return
|
||||
}
|
||||
canvas.drawRoundRect(bounds, radius, radius, paint)
|
||||
}
|
||||
|
||||
override fun onDrawForeground(
|
||||
canvas: Canvas,
|
||||
parent: RecyclerView,
|
||||
child: View,
|
||||
bounds: RectF,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
if (child !is CardView) {
|
||||
return
|
||||
}
|
||||
val radius = child.radius
|
||||
paint.color = fillColor
|
||||
paint.style = Paint.Style.FILL
|
||||
canvas.drawRoundRect(bounds, radius, radius, paint)
|
||||
paint.color = strokeColor
|
||||
paint.style = Paint.Style.STROKE
|
||||
canvas.drawRoundRect(bounds, radius, radius, paint)
|
||||
checkIcon?.run {
|
||||
setBounds(
|
||||
(bounds.right - iconSize - iconOffset).toInt(),
|
||||
(bounds.top + iconOffset).toInt(),
|
||||
(bounds.right - iconOffset).toInt(),
|
||||
(bounds.top + iconOffset + iconSize).toInt(),
|
||||
)
|
||||
draw(canvas)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@ data class ChapterListItem(
|
||||
val isNew: Boolean
|
||||
get() = hasFlag(FLAG_NEW)
|
||||
|
||||
val isGrid: Boolean
|
||||
get() = hasFlag(FLAG_GRID)
|
||||
|
||||
private fun buildDescription(): String {
|
||||
val joiner = StringJoiner(" • ")
|
||||
chapter.formatNumber()?.let {
|
||||
@@ -90,5 +93,6 @@ data class ChapterListItem(
|
||||
const val FLAG_NEW = 8
|
||||
const val FLAG_BOOKMARKED = 16
|
||||
const val FLAG_DOWNLOADED = 32
|
||||
const val FLAG_GRID = 64
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.details.ui.model
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_BOOKMARKED
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_CURRENT
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_DOWNLOADED
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_GRID
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_NEW
|
||||
import org.koitharu.kotatsu.details.ui.model.ChapterListItem.Companion.FLAG_UNREAD
|
||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||
@@ -13,6 +14,7 @@ fun MangaChapter.toListItem(
|
||||
isNew: Boolean,
|
||||
isDownloaded: Boolean,
|
||||
isBookmarked: Boolean,
|
||||
isGrid: Boolean,
|
||||
): ChapterListItem {
|
||||
var flags = 0
|
||||
if (isCurrent) flags = flags or FLAG_CURRENT
|
||||
@@ -20,6 +22,7 @@ fun MangaChapter.toListItem(
|
||||
if (isNew) flags = flags or FLAG_NEW
|
||||
if (isBookmarked) flags = flags or FLAG_BOOKMARKED
|
||||
if (isDownloaded) flags = flags or FLAG_DOWNLOADED
|
||||
if (isGrid) flags = flags or FLAG_GRID
|
||||
return ChapterListItem(
|
||||
chapter = this,
|
||||
flags = flags,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||
import org.koitharu.kotatsu.core.ui.util.ActionModeListener
|
||||
import org.koitharu.kotatsu.core.util.ext.doOnPageChanged
|
||||
import org.koitharu.kotatsu.core.util.ext.menuView
|
||||
import org.koitharu.kotatsu.core.util.ext.recyclerView
|
||||
import org.koitharu.kotatsu.core.util.ext.setTabsEnabled
|
||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||
import org.koitharu.kotatsu.databinding.SheetChaptersPagesBinding
|
||||
import org.koitharu.kotatsu.details.ui.ChapterPagesMenuProvider
|
||||
import org.koitharu.kotatsu.details.ui.DetailsViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ChaptersPagesSheet : BaseAdaptiveSheet<SheetChaptersPagesBinding>(), ActionModeListener {
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private val viewModel by activityViewModels<DetailsViewModel>()
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetChaptersPagesBinding {
|
||||
return SheetChaptersPagesBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: SheetChaptersPagesBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
disableFitToContents()
|
||||
|
||||
val args = arguments ?: Bundle.EMPTY
|
||||
val adapter = DetailsPagerAdapter2(this, args.getBoolean(ARG_SHOW_PAGES, settings.isPagesTabEnabled))
|
||||
binding.pager.recyclerView?.isNestedScrollingEnabled = false
|
||||
binding.pager.offscreenPageLimit = adapter.itemCount
|
||||
binding.pager.adapter = adapter
|
||||
binding.pager.doOnPageChanged(::onPageChanged)
|
||||
TabLayoutMediator(binding.tabs, binding.pager, adapter).attach()
|
||||
binding.pager.setCurrentItem(args.getInt(ARG_TAB, settings.defaultDetailsTab), false)
|
||||
binding.tabs.isVisible = adapter.itemCount > 1
|
||||
|
||||
val menuProvider = ChapterPagesMenuProvider(viewModel, this, binding.pager, settings)
|
||||
onBackPressedDispatcher.addCallback(viewLifecycleOwner, menuProvider)
|
||||
binding.toolbar.addMenuProvider(menuProvider)
|
||||
|
||||
actionModeDelegate.addListener(this, viewLifecycleOwner)
|
||||
}
|
||||
|
||||
override fun onActionModeStarted(mode: ActionMode) {
|
||||
expandAndLock()
|
||||
viewBinding?.toolbar?.menuView?.isEnabled = false
|
||||
}
|
||||
|
||||
override fun onActionModeFinished(mode: ActionMode) {
|
||||
unlock()
|
||||
viewBinding?.toolbar?.menuView?.isEnabled = true
|
||||
}
|
||||
|
||||
override fun expandAndLock() {
|
||||
super.expandAndLock()
|
||||
adjustLockState()
|
||||
}
|
||||
|
||||
override fun unlock() {
|
||||
super.unlock()
|
||||
adjustLockState()
|
||||
}
|
||||
|
||||
private fun adjustLockState() {
|
||||
viewBinding?.run {
|
||||
pager.isUserInputEnabled = !isLocked
|
||||
tabs.setTabsEnabled(!isLocked)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPageChanged(position: Int) {
|
||||
viewBinding?.toolbar?.invalidateMenu()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val TAB_CHAPTERS = 0
|
||||
const val TAB_PAGES = 1
|
||||
const val TAB_BOOKMARKS = 2
|
||||
private const val ARG_TAB = "tag"
|
||||
private const val ARG_SHOW_PAGES = "pages"
|
||||
private const val TAG = "ChaptersPagesSheet"
|
||||
|
||||
fun show(fm: FragmentManager) {
|
||||
ChaptersPagesSheet().showDistinct(fm, TAG)
|
||||
}
|
||||
|
||||
fun show(fm: FragmentManager, showPagesTab: Boolean) {
|
||||
ChaptersPagesSheet().withArgs(1) {
|
||||
putBoolean(ARG_SHOW_PAGES, showPagesTab)
|
||||
}.showDistinct(fm, TAG)
|
||||
}
|
||||
|
||||
fun show(fm: FragmentManager, showPagesTab: Boolean, defaultTab: Int) {
|
||||
ChaptersPagesSheet().withArgs(2) {
|
||||
putBoolean(ARG_SHOW_PAGES, showPagesTab)
|
||||
putInt(ARG_TAB, defaultTab)
|
||||
}.showDistinct(fm, TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,19 @@ import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.details.ui.pager.chapters.ChaptersFragment
|
||||
import org.koitharu.kotatsu.details.ui.pager.pages.PagesFragment
|
||||
|
||||
class DetailsPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity),
|
||||
class DetailsPagerAdapter(
|
||||
activity: FragmentActivity,
|
||||
settings: AppSettings,
|
||||
) : FragmentStateAdapter(activity),
|
||||
TabLayoutMediator.TabConfigurationStrategy {
|
||||
|
||||
override fun getItemCount(): Int = 2
|
||||
val isPagesTabEnabled = settings.isPagesTabEnabled
|
||||
|
||||
override fun getItemCount(): Int = if (isPagesTabEnabled) 2 else 1
|
||||
|
||||
override fun createFragment(position: Int): Fragment = when (position) {
|
||||
0 -> ChaptersFragment()
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.details.ui.pager.bookmarks.MangaBookmarksFragment
|
||||
import org.koitharu.kotatsu.details.ui.pager.chapters.ChaptersFragment
|
||||
import org.koitharu.kotatsu.details.ui.pager.pages.PagesFragment
|
||||
|
||||
class DetailsPagerAdapter2(
|
||||
fragment: Fragment,
|
||||
val isPagesTabEnabled: Boolean,
|
||||
) : FragmentStateAdapter(fragment),
|
||||
TabLayoutMediator.TabConfigurationStrategy {
|
||||
|
||||
override fun getItemCount(): Int = if (isPagesTabEnabled) 3 else 2
|
||||
|
||||
override fun createFragment(position: Int): Fragment = when (position) {
|
||||
0 -> ChaptersFragment()
|
||||
1 -> if (isPagesTabEnabled) PagesFragment() else MangaBookmarksFragment()
|
||||
2 -> MangaBookmarksFragment()
|
||||
else -> throw IllegalArgumentException("Invalid position $position")
|
||||
}
|
||||
|
||||
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
|
||||
tab.setText(
|
||||
when (position) {
|
||||
0 -> R.string.chapters
|
||||
1 -> if (isPagesTabEnabled) R.string.pages else R.string.bookmarks
|
||||
2 -> R.string.bookmarks
|
||||
else -> 0
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager.bookmarks
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import coil.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.ui.sheet.BookmarksAdapter
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.ext.dismissParentDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.findParentCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.databinding.FragmentMangaBookmarksBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsViewModel
|
||||
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.OnPageSelectListener
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MangaBookmarksFragment : BaseFragment<FragmentMangaBookmarksBinding>(),
|
||||
OnListItemClickListener<Bookmark> {
|
||||
|
||||
private val activityViewModel by activityViewModels<DetailsViewModel>()
|
||||
private val viewModel by viewModels<MangaBookmarksViewModel>()
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
@Inject
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private var bookmarksAdapter: BookmarksAdapter? = null
|
||||
private var spanResolver: MangaListSpanResolver? = null
|
||||
|
||||
private val spanSizeLookup = SpanSizeLookup()
|
||||
private val listCommitCallback = Runnable {
|
||||
spanSizeLookup.invalidateCache()
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
activityViewModel.manga.observe(this, viewModel)
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentMangaBookmarksBinding {
|
||||
return FragmentMangaBookmarksBinding.inflate(inflater, container, false)
|
||||
}
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentMangaBookmarksBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
spanResolver = MangaListSpanResolver(binding.root.resources)
|
||||
bookmarksAdapter = BookmarksAdapter(
|
||||
coil = coil,
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
clickListener = this@MangaBookmarksFragment,
|
||||
headerClickListener = null,
|
||||
)
|
||||
with(binding.recyclerView) {
|
||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||
adapter = bookmarksAdapter
|
||||
addOnLayoutChangeListener(spanResolver)
|
||||
spanResolver?.setGridSize(settings.gridSize / 100f, this)
|
||||
(layoutManager as GridLayoutManager).spanSizeLookup = spanSizeLookup
|
||||
}
|
||||
viewModel.content.observe(viewLifecycleOwner, checkNotNull(bookmarksAdapter))
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
spanResolver = null
|
||||
bookmarksAdapter = null
|
||||
spanSizeLookup.invalidateCache()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
// required for BottomSheetBehavior
|
||||
requireViewBinding().recyclerView.isNestedScrollingEnabled = false
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
requireViewBinding().recyclerView.isNestedScrollingEnabled = true
|
||||
super.onResume()
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
||||
|
||||
override fun onItemClick(item: Bookmark, view: View) {
|
||||
val listener = findParentCallback(ReaderNavigationCallback::class.java)
|
||||
if (listener != null && listener.onBookmarkSelected(item)) {
|
||||
dismissParentDialog()
|
||||
} else {
|
||||
val intent = IntentBuilder(view.context)
|
||||
.manga(activityViewModel.manga.value ?: return)
|
||||
.bookmark(item)
|
||||
.incognito(true)
|
||||
.build()
|
||||
startActivity(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup() {
|
||||
|
||||
init {
|
||||
isSpanIndexCacheEnabled = true
|
||||
isSpanGroupIndexCacheEnabled = true
|
||||
}
|
||||
|
||||
override fun getSpanSize(position: Int): Int {
|
||||
val total = (viewBinding?.recyclerView?.layoutManager as? GridLayoutManager)?.spanCount ?: return 1
|
||||
return when (bookmarksAdapter?.getItemViewType(position)) {
|
||||
ListItemType.PAGE_THUMB.ordinal -> 1
|
||||
else -> total
|
||||
}
|
||||
}
|
||||
|
||||
fun invalidateCache() {
|
||||
invalidateSpanGroupIndexCache()
|
||||
invalidateSpanIndexCache()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager.bookmarks
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class MangaBookmarksViewModel @Inject constructor(
|
||||
bookmarksRepository: BookmarksRepository,
|
||||
) : BaseViewModel(), FlowCollector<Manga?> {
|
||||
|
||||
private val manga = MutableStateFlow<Manga?>(null)
|
||||
|
||||
val content: StateFlow<List<ListModel>> = manga.filterNotNull().flatMapLatest { m ->
|
||||
bookmarksRepository.observeBookmarks(m)
|
||||
.map { mapList(m, it) }
|
||||
}.withErrorHandling()
|
||||
.filterNotNull()
|
||||
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState))
|
||||
|
||||
override suspend fun emit(value: Manga?) {
|
||||
manga.value = value
|
||||
}
|
||||
|
||||
private suspend fun mapList(manga: Manga, bookmarks: List<Bookmark>): List<ListModel>? {
|
||||
val chapters = manga.chapters ?: return null
|
||||
val bookmarksMap = bookmarks.groupBy { it.chapterId }
|
||||
val result = ArrayList<ListModel>(bookmarks.size + bookmarksMap.size)
|
||||
for (chapter in chapters) {
|
||||
val b = bookmarksMap[chapter.id]
|
||||
if (b.isNullOrEmpty()) {
|
||||
continue
|
||||
}
|
||||
result += ListHeader(chapter.name)
|
||||
result.addAll(b)
|
||||
}
|
||||
if (result.isEmpty()) {
|
||||
result.add(
|
||||
EmptyState(
|
||||
icon = 0,
|
||||
textPrimary = R.string.no_bookmarks_yet,
|
||||
textSecondary = R.string.no_bookmarks_summary,
|
||||
actionStringRes = 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager.chapters
|
||||
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class ChapterGridSpanHelper private constructor() : View.OnLayoutChangeListener {
|
||||
|
||||
override fun onLayoutChange(
|
||||
v: View?,
|
||||
left: Int,
|
||||
top: Int,
|
||||
right: Int,
|
||||
bottom: Int,
|
||||
oldLeft: Int,
|
||||
oldTop: Int,
|
||||
oldRight: Int,
|
||||
oldBottom: Int
|
||||
) {
|
||||
val rv = v as? RecyclerView ?: return
|
||||
if (rv.width > 0) {
|
||||
apply(rv)
|
||||
}
|
||||
}
|
||||
|
||||
private fun apply(rv: RecyclerView) {
|
||||
(rv.layoutManager as? GridLayoutManager)?.spanCount = getSpanCount(rv)
|
||||
}
|
||||
|
||||
class SpanSizeLookup(
|
||||
private val recyclerView: RecyclerView
|
||||
) : GridLayoutManager.SpanSizeLookup() {
|
||||
|
||||
override fun getSpanSize(position: Int): Int {
|
||||
return when (recyclerView.adapter?.getItemViewType(position)) {
|
||||
ListItemType.CHAPTER_LIST.ordinal, // for smooth transition
|
||||
ListItemType.HEADER.ordinal -> getTotalSpans()
|
||||
|
||||
else -> 1
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTotalSpans() = (recyclerView.layoutManager as? GridLayoutManager)?.spanCount ?: 1
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
fun attach(view: RecyclerView) {
|
||||
val helper = ChapterGridSpanHelper()
|
||||
view.addOnLayoutChangeListener(helper)
|
||||
helper.apply(view)
|
||||
}
|
||||
|
||||
fun getSpanCount(view: RecyclerView): Int {
|
||||
val cellWidth = view.resources.getDimension(R.dimen.chapter_grid_width)
|
||||
val estimatedCount = (view.width / cellWidth).roundToInt()
|
||||
return estimatedCount.coerceAtLeast(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import androidx.core.graphics.Insets
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
@@ -20,6 +22,9 @@ import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.dismissParentDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
|
||||
import org.koitharu.kotatsu.core.util.ext.findParentCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
|
||||
@@ -35,6 +40,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -57,17 +63,27 @@ class ChaptersFragment :
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
chaptersAdapter = ChaptersAdapter(this)
|
||||
selectionController = ListSelectionController(
|
||||
activity = requireActivity(),
|
||||
appCompatDelegate = checkNotNull(findAppCompatDelegate()),
|
||||
decoration = ChaptersSelectionDecoration(binding.root.context),
|
||||
registryOwner = this,
|
||||
callback = this,
|
||||
)
|
||||
viewModel.isChaptersInGridView.observe(viewLifecycleOwner) { chaptersInGridView ->
|
||||
binding.recyclerViewChapters.layoutManager = if (chaptersInGridView) {
|
||||
GridLayoutManager(context, ChapterGridSpanHelper.getSpanCount(binding.recyclerViewChapters)).apply {
|
||||
spanSizeLookup = ChapterGridSpanHelper.SpanSizeLookup(binding.recyclerViewChapters)
|
||||
}
|
||||
} else {
|
||||
LinearLayoutManager(context)
|
||||
}
|
||||
}
|
||||
with(binding.recyclerViewChapters) {
|
||||
addItemDecoration(TypedListSpacingDecoration(context, true))
|
||||
checkNotNull(selectionController).attachToRecyclerView(this)
|
||||
setHasFixedSize(true)
|
||||
isNestedScrollingEnabled = false
|
||||
adapter = chaptersAdapter
|
||||
ChapterGridSpanHelper.attach(this)
|
||||
}
|
||||
viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged)
|
||||
viewModel.chapters
|
||||
@@ -109,12 +125,17 @@ class ChaptersFragment :
|
||||
if (selectionController?.onItemClick(item.chapter.id) == true) {
|
||||
return
|
||||
}
|
||||
startActivity(
|
||||
IntentBuilder(view.context)
|
||||
.manga(viewModel.manga.value ?: return)
|
||||
.state(ReaderState(item.chapter.id, 0, 0))
|
||||
.build(),
|
||||
)
|
||||
val listener = findParentCallback(ReaderNavigationCallback::class.java)
|
||||
if (listener != null && listener.onChapterSelected(item.chapter)) {
|
||||
dismissParentDialog()
|
||||
} else {
|
||||
startActivity(
|
||||
IntentBuilder(view.context)
|
||||
.manga(viewModel.manga.value ?: return)
|
||||
.state(ReaderState(item.chapter.id, 0, 0))
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemLongClick(item: ChapterListItem, view: View): Boolean {
|
||||
@@ -234,7 +255,7 @@ class ChaptersFragment :
|
||||
}
|
||||
|
||||
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
|
||||
requireViewBinding().recyclerViewChapters.invalidateItemDecorations()
|
||||
viewBinding?.recyclerViewChapters?.invalidateItemDecorations()
|
||||
}
|
||||
|
||||
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
||||
|
||||
@@ -13,7 +13,9 @@ import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import coil.ImageLoader
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
@@ -21,16 +23,18 @@ import org.koitharu.kotatsu.core.ui.BaseFragment
|
||||
import org.koitharu.kotatsu.core.ui.list.BoundsScrollListener
|
||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.dismissParentDialog
|
||||
import org.koitharu.kotatsu.core.util.ext.findParentCallback
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.core.util.ext.showOrHide
|
||||
import org.koitharu.kotatsu.databinding.FragmentPagesBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsViewModel
|
||||
import org.koitharu.kotatsu.list.ui.MangaListSpanResolver
|
||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
|
||||
import org.koitharu.kotatsu.reader.ui.ReaderState
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
|
||||
import org.koitharu.kotatsu.reader.ui.thumbnails.adapter.PageThumbnailAdapter
|
||||
@@ -52,7 +56,7 @@ class PagesFragment :
|
||||
lateinit var settings: AppSettings
|
||||
|
||||
private var thumbnailsAdapter: PageThumbnailAdapter? = null
|
||||
private var spanResolver: MangaListSpanResolver? = null
|
||||
private var spanResolver: PagesGridSpanResolver? = null
|
||||
private var scrollListener: ScrollListener? = null
|
||||
|
||||
private val spanSizeLookup = SpanSizeLookup()
|
||||
@@ -65,11 +69,12 @@ class PagesFragment :
|
||||
detailsViewModel.selectedBranch,
|
||||
) { details, history, branch ->
|
||||
if (details != null && (details.isLoaded || details.chapters.isNotEmpty())) {
|
||||
PagesViewModel.State(details, history, branch)
|
||||
PagesViewModel.State(details.filterChapters(branch), history, branch)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.observe(this, viewModel::updateState)
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.observe(this, viewModel::updateState)
|
||||
}
|
||||
|
||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentPagesBinding {
|
||||
@@ -78,19 +83,19 @@ class PagesFragment :
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentPagesBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
spanResolver = MangaListSpanResolver(binding.root.resources)
|
||||
spanResolver = PagesGridSpanResolver(binding.root.resources)
|
||||
thumbnailsAdapter = PageThumbnailAdapter(
|
||||
coil = coil,
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
clickListener = this@PagesFragment,
|
||||
)
|
||||
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) // before rv initialization
|
||||
with(binding.recyclerView) {
|
||||
addItemDecoration(TypedListSpacingDecoration(context, false))
|
||||
adapter = thumbnailsAdapter
|
||||
setHasFixedSize(true)
|
||||
isNestedScrollingEnabled = false
|
||||
addOnLayoutChangeListener(spanResolver)
|
||||
spanResolver?.setGridSize(settings.gridSize / 100f, this)
|
||||
addOnScrollListener(ScrollListener().also { scrollListener = it })
|
||||
(layoutManager as GridLayoutManager).let {
|
||||
it.spanSizeLookup = spanSizeLookup
|
||||
@@ -127,10 +132,17 @@ class PagesFragment :
|
||||
override fun onWindowInsetsChanged(insets: Insets) = Unit
|
||||
|
||||
override fun onItemClick(item: PageThumbnail, view: View) {
|
||||
val manga = detailsViewModel.manga.value ?: return
|
||||
val state = ReaderState(item.page.chapterId, item.page.index, 0)
|
||||
val intent = IntentBuilder(view.context).manga(manga).state(state).build()
|
||||
startActivity(intent)
|
||||
val listener = findParentCallback(ReaderNavigationCallback::class.java)
|
||||
if (listener != null && listener.onPageSelected(item.page)) {
|
||||
dismissParentDialog()
|
||||
} else {
|
||||
startActivity(
|
||||
IntentBuilder(view.context)
|
||||
.manga(detailsViewModel.manga.value ?: return)
|
||||
.state(ReaderState(item.page.chapterId, item.page.index, 0))
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onThumbnailsChanged(list: List<ListModel>) {
|
||||
@@ -160,6 +172,11 @@ class PagesFragment :
|
||||
}
|
||||
}
|
||||
|
||||
private fun onGridScaleChanged(scale: Float) {
|
||||
spanSizeLookup.invalidateCache()
|
||||
spanResolver?.setGridSize(scale, requireViewBinding().recyclerView)
|
||||
}
|
||||
|
||||
private fun onNoChaptersChanged(isNoChapters: Boolean) {
|
||||
with(viewBinding ?: return) {
|
||||
textViewHolder.isVisible = isNoChapters
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager.pages
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.koitharu.kotatsu.R
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class PagesGridSpanResolver(
|
||||
resources: Resources,
|
||||
) : View.OnLayoutChangeListener {
|
||||
|
||||
var spanCount = 3
|
||||
private set
|
||||
|
||||
private val gridWidth = resources.getDimension(R.dimen.preferred_grid_width)
|
||||
private val spacing = resources.getDimension(R.dimen.grid_spacing)
|
||||
private var cellWidth = -1f
|
||||
|
||||
override fun onLayoutChange(
|
||||
v: View?,
|
||||
left: Int,
|
||||
top: Int,
|
||||
right: Int,
|
||||
bottom: Int,
|
||||
oldLeft: Int,
|
||||
oldTop: Int,
|
||||
oldRight: Int,
|
||||
oldBottom: Int,
|
||||
) {
|
||||
if (cellWidth <= 0f) {
|
||||
return
|
||||
}
|
||||
val rv = v as? RecyclerView ?: return
|
||||
val width = abs(right - left)
|
||||
if (width == 0) {
|
||||
return
|
||||
}
|
||||
resolveGridSpanCount(width)
|
||||
(rv.layoutManager as? GridLayoutManager)?.spanCount = spanCount
|
||||
}
|
||||
|
||||
fun setGridSize(scaleFactor: Float, rv: RecyclerView) {
|
||||
cellWidth = (gridWidth * scaleFactor) + spacing
|
||||
val lm = rv.layoutManager as? GridLayoutManager ?: return
|
||||
val innerWidth = lm.width - lm.paddingEnd - lm.paddingStart
|
||||
if (innerWidth > 0) {
|
||||
resolveGridSpanCount(innerWidth)
|
||||
lm.spanCount = spanCount
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveGridSpanCount(width: Int) {
|
||||
val estimatedCount = (width / cellWidth).roundToInt()
|
||||
spanCount = estimatedCount.coerceAtLeast(2)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
package org.koitharu.kotatsu.details.ui.pager.pages
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.plus
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
|
||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||
import org.koitharu.kotatsu.core.util.ext.firstNotNull
|
||||
import org.koitharu.kotatsu.details.data.MangaDetails
|
||||
@@ -18,6 +22,7 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class PagesViewModel @Inject constructor(
|
||||
private val chaptersLoader: ChaptersLoader,
|
||||
private val settings: AppSettings,
|
||||
) : BaseViewModel() {
|
||||
|
||||
private var loadingJob: Job? = null
|
||||
@@ -29,6 +34,12 @@ class PagesViewModel @Inject constructor(
|
||||
val isLoadingUp = MutableStateFlow(false)
|
||||
val isLoadingDown = MutableStateFlow(false)
|
||||
|
||||
val gridScale = settings.observeAsStateFlow(
|
||||
scope = viewModelScope + Dispatchers.Default,
|
||||
key = AppSettings.KEY_GRID_SIZE_PAGES,
|
||||
valueProducer = { gridSizePages / 100f },
|
||||
)
|
||||
|
||||
init {
|
||||
loadingJob = launchLoadingJob(Dispatchers.Default) {
|
||||
val firstState = state.firstNotNull()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user