Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
54c646ceb0
|
|||
|
3599f2f1b8
|
|||
|
b2e53d4938
|
|||
|
0d62408918
|
|||
|
2ae046d4c5
|
|||
|
66356dc094
|
|||
|
ae16110a80
|
|||
| c3aff60a9c | |||
|
|
cfdca3434b | ||
|
|
b2c2693aba | ||
|
|
5901c26ae0 | ||
|
|
d864c73faf | ||
|
|
30551a56b2 | ||
|
|
23cb023a85 | ||
|
|
10291d5b29 | ||
|
|
e05e09f846 | ||
|
0ce7f7cf6b
|
|||
|
4d9d15004c
|
|||
|
1908ce3e46
|
|||
|
6c07abec56
|
|||
|
64dc646fc5
|
|||
|
357669d8b2
|
|||
|
21639ddcbc
|
|||
|
5183d5e882
|
|||
|
3008b7b89a
|
|||
|
53e00e4689
|
|||
|
963d7d8d42
|
|||
|
1a7b1e7bdc
|
|||
|
b1fa9d1d22
|
|||
|
91179ef901
|
|||
|
a7a9ee9d59
|
|||
|
ff05f3f79d
|
|||
|
c0062c83c8
|
|||
|
ef0cf4766a
|
|||
|
910069ec99
|
|||
|
d56107bf1f
|
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,2 +0,0 @@
|
|||||||
ko_fi: xtimms
|
|
||||||
custom: ["https://yoomoney.ru/to/410012543938752"]
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@
|
|||||||
/.idea/kotlinc.xml
|
/.idea/kotlinc.xml
|
||||||
/.idea/deploymentTargetDropDown.xml
|
/.idea/deploymentTargetDropDown.xml
|
||||||
/.idea/androidTestResultsUserPreferences.xml
|
/.idea/androidTestResultsUserPreferences.xml
|
||||||
|
/.idea/deploymentTargetSelector.xml
|
||||||
/.idea/render.experimental.xml
|
/.idea/render.experimental.xml
|
||||||
/.idea/inspectionProfiles/
|
/.idea/inspectionProfiles/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ android {
|
|||||||
applicationId 'org.koitharu.kotatsu'
|
applicationId 'org.koitharu.kotatsu'
|
||||||
minSdk = 21
|
minSdk = 21
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 601
|
versionCode = 603
|
||||||
versionName = '6.4.1'
|
versionName = '6.5-b1'
|
||||||
generatedDensities = []
|
generatedDensities = []
|
||||||
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
|
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
|
||||||
ksp {
|
ksp {
|
||||||
// arg("room.generateKotlin", "true") TODO: enable later
|
arg('room.generateKotlin', 'true')
|
||||||
arg("room.schemaLocation", "$projectDir/schemas")
|
arg('room.schemaLocation', "$projectDir/schemas")
|
||||||
}
|
}
|
||||||
androidResources {
|
androidResources {
|
||||||
generateLocaleConfig true
|
generateLocaleConfig true
|
||||||
@@ -82,12 +82,12 @@ afterEvaluate {
|
|||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
//noinspection GradleDependency
|
//noinspection GradleDependency
|
||||||
implementation('com.github.KotatsuApp:kotatsu-parsers:3bb10ee948') {
|
implementation('com.github.KotatsuApp:kotatsu-parsers:d6b578acb0') {
|
||||||
exclude group: 'org.json', module: 'json'
|
exclude group: 'org.json', module: 'json'
|
||||||
}
|
}
|
||||||
|
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4'
|
||||||
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.20'
|
implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.21'
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||||
|
|
||||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
@@ -95,7 +95,6 @@ dependencies {
|
|||||||
implementation 'androidx.activity:activity-ktx:1.8.1'
|
implementation 'androidx.activity:activity-ktx:1.8.1'
|
||||||
implementation 'androidx.fragment:fragment-ktx:1.6.2'
|
implementation 'androidx.fragment:fragment-ktx:1.6.2'
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2'
|
||||||
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2'
|
|
||||||
implementation 'androidx.lifecycle:lifecycle-service:2.6.2'
|
implementation 'androidx.lifecycle:lifecycle-service:2.6.2'
|
||||||
implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
|
implementation 'androidx.lifecycle:lifecycle-process:2.6.2'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||||
@@ -107,8 +106,7 @@ dependencies {
|
|||||||
implementation 'com.google.android.material:material:1.10.0'
|
implementation 'com.google.android.material:material:1.10.0'
|
||||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.2'
|
implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.2'
|
||||||
|
|
||||||
// TODO https://issuetracker.google.com/issues/254846063
|
implementation 'androidx.work:work-runtime:2.9.0'
|
||||||
implementation 'androidx.work:work-runtime-ktx:2.8.1'
|
|
||||||
//noinspection GradleDependency
|
//noinspection GradleDependency
|
||||||
implementation('com.google.guava:guava:32.0.1-android') {
|
implementation('com.google.guava:guava:32.0.1-android') {
|
||||||
exclude group: 'com.google.guava', module: 'failureaccess'
|
exclude group: 'com.google.guava', module: 'failureaccess'
|
||||||
@@ -116,9 +114,9 @@ dependencies {
|
|||||||
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
exclude group: 'com.google.j2objc', module: 'j2objc-annotations'
|
||||||
}
|
}
|
||||||
|
|
||||||
implementation 'androidx.room:room-runtime:2.6.0'
|
implementation 'androidx.room:room-runtime:2.6.1'
|
||||||
implementation 'androidx.room:room-ktx:2.6.0'
|
implementation 'androidx.room:room-ktx:2.6.1'
|
||||||
ksp 'androidx.room:room-compiler:2.6.0'
|
ksp 'androidx.room:room-compiler:2.6.1'
|
||||||
|
|
||||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||||
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
|
implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0'
|
||||||
@@ -127,14 +125,14 @@ dependencies {
|
|||||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2'
|
||||||
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2'
|
||||||
|
|
||||||
implementation 'com.google.dagger:hilt-android:2.48.1'
|
implementation 'com.google.dagger:hilt-android:2.49'
|
||||||
kapt 'com.google.dagger:hilt-compiler:2.48.1'
|
kapt 'com.google.dagger:hilt-compiler:2.49'
|
||||||
implementation 'androidx.hilt:hilt-work:1.1.0'
|
implementation 'androidx.hilt:hilt-work:1.1.0'
|
||||||
kapt 'androidx.hilt:hilt-compiler:1.1.0'
|
kapt 'androidx.hilt:hilt-compiler:1.1.0'
|
||||||
|
|
||||||
implementation 'io.coil-kt:coil-base:2.5.0'
|
implementation 'io.coil-kt:coil-base:2.5.0'
|
||||||
implementation 'io.coil-kt:coil-svg:2.5.0'
|
implementation 'io.coil-kt:coil-svg:2.5.0'
|
||||||
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:771c8753ae'
|
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:02e6d6cfe9'
|
||||||
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
implementation 'com.github.solkin:disk-lru-cache:1.4'
|
||||||
implementation 'io.noties.markwon:core:4.6.2'
|
implementation 'io.noties.markwon:core:4.6.2'
|
||||||
|
|
||||||
@@ -154,9 +152,9 @@ dependencies {
|
|||||||
|
|
||||||
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
||||||
|
|
||||||
androidTestImplementation 'androidx.room:room-testing:2.6.0'
|
androidTestImplementation 'androidx.room:room-testing:2.6.1'
|
||||||
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
|
androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
|
||||||
|
|
||||||
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48.1'
|
androidTestImplementation 'com.google.dagger:hilt-android-testing:2.49'
|
||||||
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.48.1'
|
kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.49'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.work.WorkManager
|
|||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.acra.ACRA
|
import org.acra.ACRA
|
||||||
import org.acra.ReportField
|
import org.acra.ReportField
|
||||||
import org.acra.config.dialog
|
import org.acra.config.dialog
|
||||||
@@ -39,7 +40,7 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks>
|
lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks>
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var database: MangaDatabase
|
lateinit var database: Provider<MangaDatabase>
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var settings: AppSettings
|
lateinit var settings: AppSettings
|
||||||
@@ -51,21 +52,31 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
lateinit var appValidator: AppValidator
|
lateinit var appValidator: AppValidator
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var workScheduleManager: WorkScheduleManager
|
lateinit var workScheduleManager: Provider<WorkScheduleManager>
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var workManagerProvider: Provider<WorkManager>
|
lateinit var workManagerProvider: Provider<WorkManager>
|
||||||
|
|
||||||
|
override val workManagerConfiguration: Configuration
|
||||||
|
get() = Configuration.Builder()
|
||||||
|
.setWorkerFactory(workerFactory)
|
||||||
|
.build()
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString())
|
|
||||||
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
AppCompatDelegate.setDefaultNightMode(settings.theme)
|
||||||
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
AppCompatDelegate.setApplicationLocales(settings.appLocales)
|
||||||
setupActivityLifecycleCallbacks()
|
setupActivityLifecycleCallbacks()
|
||||||
|
processLifecycleScope.launch {
|
||||||
|
val isOriginalApp = withContext(Dispatchers.Default) {
|
||||||
|
appValidator.isOriginalApp
|
||||||
|
}
|
||||||
|
ACRA.errorReporter.putCustomData("isOriginalApp", isOriginalApp.toString())
|
||||||
|
}
|
||||||
processLifecycleScope.launch(Dispatchers.Default) {
|
processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
setupDatabaseObservers()
|
setupDatabaseObservers()
|
||||||
}
|
}
|
||||||
workScheduleManager.init()
|
workScheduleManager.get().init()
|
||||||
WorkServiceStopHelper(workManagerProvider).setup()
|
WorkServiceStopHelper(workManagerProvider).setup()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,13 +85,6 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
initAcra {
|
initAcra {
|
||||||
buildConfigClass = BuildConfig::class.java
|
buildConfigClass = BuildConfig::class.java
|
||||||
reportFormat = StringFormat.JSON
|
reportFormat = StringFormat.JSON
|
||||||
excludeMatchingSharedPreferencesKeys = listOf(
|
|
||||||
"sources_\\w+",
|
|
||||||
AppSettings.KEY_APP_PASSWORD,
|
|
||||||
AppSettings.KEY_PROXY_LOGIN,
|
|
||||||
AppSettings.KEY_PROXY_ADDRESS,
|
|
||||||
AppSettings.KEY_PROXY_PASSWORD,
|
|
||||||
)
|
|
||||||
httpSender {
|
httpSender {
|
||||||
uri = getString(R.string.url_error_report)
|
uri = getString(R.string.url_error_report)
|
||||||
basicAuthLogin = getString(R.string.acra_login)
|
basicAuthLogin = getString(R.string.acra_login)
|
||||||
@@ -97,7 +101,6 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
ReportField.STACK_TRACE,
|
ReportField.STACK_TRACE,
|
||||||
ReportField.CRASH_CONFIGURATION,
|
ReportField.CRASH_CONFIGURATION,
|
||||||
ReportField.CUSTOM_DATA,
|
ReportField.CUSTOM_DATA,
|
||||||
ReportField.SHARED_PREFERENCES,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
dialog {
|
dialog {
|
||||||
@@ -110,15 +113,9 @@ open class BaseApp : Application(), Configuration.Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getWorkManagerConfiguration(): Configuration {
|
|
||||||
return Configuration.Builder()
|
|
||||||
.setWorkerFactory(workerFactory)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
private fun setupDatabaseObservers() {
|
private fun setupDatabaseObservers() {
|
||||||
val tracker = database.invalidationTracker
|
val tracker = database.get().invalidationTracker
|
||||||
databaseObservers.forEach {
|
databaseObservers.forEach {
|
||||||
tracker.addObserver(it)
|
tracker.addObserver(it)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class ExpiringLruCache<T>(
|
|||||||
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
|
private val cache = LruCache<ContentCache.Key, ExpiringValue<T>>(maxSize)
|
||||||
|
|
||||||
operator fun get(key: ContentCache.Key): T? {
|
operator fun get(key: ContentCache.Key): T? {
|
||||||
val value = cache.get(key) ?: return null
|
val value = cache[key] ?: return null
|
||||||
if (value.isExpired) {
|
if (value.isExpired) {
|
||||||
cache.remove(key)
|
cache.remove(key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import org.koitharu.kotatsu.core.db.migrations.Migration13To14
|
|||||||
import org.koitharu.kotatsu.core.db.migrations.Migration14To15
|
import org.koitharu.kotatsu.core.db.migrations.Migration14To15
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration15To16
|
import org.koitharu.kotatsu.core.db.migrations.Migration15To16
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration16To17
|
import org.koitharu.kotatsu.core.db.migrations.Migration16To17
|
||||||
|
import org.koitharu.kotatsu.core.db.migrations.Migration17To18
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
import org.koitharu.kotatsu.core.db.migrations.Migration1To2
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
import org.koitharu.kotatsu.core.db.migrations.Migration2To3
|
||||||
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
|
||||||
@@ -53,7 +54,7 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity
|
|||||||
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
import org.koitharu.kotatsu.tracker.data.TrackLogEntity
|
||||||
import org.koitharu.kotatsu.tracker.data.TracksDao
|
import org.koitharu.kotatsu.tracker.data.TracksDao
|
||||||
|
|
||||||
const val DATABASE_VERSION = 17
|
const val DATABASE_VERSION = 18
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
@@ -108,6 +109,7 @@ fun getDatabaseMigrations(context: Context): Array<Migration> = arrayOf(
|
|||||||
Migration14To15(),
|
Migration14To15(),
|
||||||
Migration15To16(),
|
Migration15To16(),
|
||||||
Migration16To17(context),
|
Migration16To17(context),
|
||||||
|
Migration17To18(),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun MangaDatabase(context: Context): MangaDatabase = Room
|
fun MangaDatabase(context: Context): MangaDatabase = Room
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.core.db.dao
|
package org.koitharu.kotatsu.core.db.dao
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Delete
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
@@ -23,6 +24,10 @@ abstract class MangaDao {
|
|||||||
@Query("SELECT * FROM manga WHERE public_url = :publicUrl")
|
@Query("SELECT * FROM manga WHERE public_url = :publicUrl")
|
||||||
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
|
abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags?
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Query("SELECT * FROM manga WHERE source = :source")
|
||||||
|
abstract suspend fun findAllBySource(source: String): List<MangaWithTags>
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
|
@Query("SELECT * FROM manga WHERE (title LIKE :query OR alt_title LIKE :query) AND manga_id IN (SELECT manga_id FROM favourites UNION SELECT manga_id FROM history) LIMIT :limit")
|
||||||
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
|
abstract suspend fun searchByTitle(query: String, limit: Int): List<MangaWithTags>
|
||||||
@@ -43,6 +48,10 @@ abstract class MangaDao {
|
|||||||
@Query("DELETE FROM manga_tags WHERE manga_id = :mangaId")
|
@Query("DELETE FROM manga_tags WHERE manga_id = :mangaId")
|
||||||
abstract suspend fun clearTagRelation(mangaId: Long)
|
abstract suspend fun clearTagRelation(mangaId: Long)
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
@Delete
|
||||||
|
abstract suspend fun delete(subjects: Collection<MangaEntity>)
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
|
open suspend fun upsert(manga: MangaEntity, tags: Iterable<TagEntity>? = null) {
|
||||||
upsert(manga)
|
upsert(manga)
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ data class MangaPrefsEntity(
|
|||||||
@ColumnInfo(name = "cf_brightness") val cfBrightness: Float,
|
@ColumnInfo(name = "cf_brightness") val cfBrightness: Float,
|
||||||
@ColumnInfo(name = "cf_contrast") val cfContrast: Float,
|
@ColumnInfo(name = "cf_contrast") val cfContrast: Float,
|
||||||
@ColumnInfo(name = "cf_invert") val cfInvert: Boolean,
|
@ColumnInfo(name = "cf_invert") val cfInvert: Boolean,
|
||||||
|
@ColumnInfo(name = "cf_grayscale") val cfGrayscale: Boolean,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.koitharu.kotatsu.core.db.migrations
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
class Migration17To18 : Migration(17, 18) {
|
||||||
|
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE preferences ADD COLUMN `cf_grayscale` INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.exceptions
|
|
||||||
|
|
||||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
|
||||||
|
|
||||||
class CompositeException(val errors: Collection<Throwable>) : Exception() {
|
|
||||||
|
|
||||||
override val message: String = errors.mapNotNullToSet { it.message }.joinToString()
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.koitharu.kotatsu.core.model
|
package org.koitharu.kotatsu.core.model
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
@@ -43,6 +44,15 @@ val MangaState.titleResId: Int
|
|||||||
MangaState.PAUSED -> R.string.state_paused
|
MangaState.PAUSED -> R.string.state_paused
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@get:DrawableRes
|
||||||
|
val MangaState.iconResId: Int
|
||||||
|
get() = when (this) {
|
||||||
|
MangaState.ONGOING -> R.drawable.ic_state_ongoing
|
||||||
|
MangaState.FINISHED -> R.drawable.ic_state_finished
|
||||||
|
MangaState.ABANDONED -> R.drawable.ic_state_abandoned
|
||||||
|
MangaState.PAUSED -> R.drawable.ic_action_pause
|
||||||
|
}
|
||||||
|
|
||||||
fun Manga.findChapter(id: Long): MangaChapter? {
|
fun Manga.findChapter(id: Long): MangaChapter? {
|
||||||
return chapters?.findById(id)
|
return chapters?.findById(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.core.parser
|
package org.koitharu.kotatsu.core.parser
|
||||||
|
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.room.withTransaction
|
import androidx.room.withTransaction
|
||||||
import dagger.Reusable
|
import dagger.Reusable
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -13,6 +14,7 @@ import org.koitharu.kotatsu.core.db.entity.toManga
|
|||||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||||
import org.koitharu.kotatsu.core.model.isLocal
|
import org.koitharu.kotatsu.core.model.isLocal
|
||||||
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
import org.koitharu.kotatsu.core.prefs.ReaderMode
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
@@ -97,9 +99,18 @@ class MangaDataRepository @Inject constructor(
|
|||||||
return db.getTagsDao().findTags(source.name).toMangaTags()
|
return db.getTagsDao().findTags(source.name).toMangaTags()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun cleanupLocalManga() {
|
||||||
|
val dao = db.getMangaDao()
|
||||||
|
val broken = dao.findAllBySource(MangaSource.LOCAL.name)
|
||||||
|
.filter { x -> x.manga.url.toUri().toFileOrNull()?.exists() == false }
|
||||||
|
if (broken.isNotEmpty()) {
|
||||||
|
dao.delete(broken.map { it.manga })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {
|
private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {
|
||||||
return if (cfBrightness != 0f || cfContrast != 0f || cfInvert) {
|
return if (cfBrightness != 0f || cfContrast != 0f || cfInvert || cfGrayscale) {
|
||||||
ReaderColorFilter(cfBrightness, cfContrast, cfInvert)
|
ReaderColorFilter(cfBrightness, cfContrast, cfInvert, cfGrayscale)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -111,5 +122,6 @@ class MangaDataRepository @Inject constructor(
|
|||||||
cfBrightness = 0f,
|
cfBrightness = 0f,
|
||||||
cfContrast = 0f,
|
cfContrast = 0f,
|
||||||
cfInvert = false,
|
cfInvert = false,
|
||||||
|
cfGrayscale = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
|
|||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
import java.util.EnumMap
|
import java.util.EnumMap
|
||||||
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.collections.set
|
import kotlin.collections.set
|
||||||
@@ -41,6 +42,8 @@ interface MangaRepository {
|
|||||||
|
|
||||||
suspend fun getTags(): Set<MangaTag>
|
suspend fun getTags(): Set<MangaTag>
|
||||||
|
|
||||||
|
suspend fun getLocales(): Set<Locale>
|
||||||
|
|
||||||
suspend fun getRelated(seed: Manga): List<Manga>
|
suspend fun getRelated(seed: Manga): List<Manga>
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag
|
|||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import org.koitharu.kotatsu.parsers.util.domain
|
import org.koitharu.kotatsu.parsers.util.domain
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
class RemoteMangaRepository(
|
class RemoteMangaRepository(
|
||||||
private val parser: MangaParser,
|
private val parser: MangaParser,
|
||||||
@@ -104,6 +105,10 @@ class RemoteMangaRepository(
|
|||||||
parser.getAvailableTags()
|
parser.getAvailableTags()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getLocales(): Set<Locale> {
|
||||||
|
return parser.getAvailableLocales()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
|
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching {
|
||||||
parser.getFavicons()
|
parser.getFavicons()
|
||||||
}
|
}
|
||||||
@@ -151,6 +156,10 @@ class RemoteMangaRepository(
|
|||||||
return parser.configKeyDomain.presetValues.toList()
|
return parser.configKeyDomain.presetValues.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isSlowdownEnabled(): Boolean {
|
||||||
|
return getConfig().isSlowdownEnabled
|
||||||
|
}
|
||||||
|
|
||||||
private fun getConfig() = parser.config as SourceSettings
|
private fun getConfig() = parser.config as SourceSettings
|
||||||
|
|
||||||
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
|
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
|||||||
import org.koitharu.kotatsu.parsers.util.find
|
import org.koitharu.kotatsu.parsers.util.find
|
||||||
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.Proxy
|
import java.net.Proxy
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -259,9 +260,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val isDownloadsSlowdownEnabled: Boolean
|
|
||||||
get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false)
|
|
||||||
|
|
||||||
val isDownloadsWiFiOnly: Boolean
|
val isDownloadsWiFiOnly: Boolean
|
||||||
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
|
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
|
||||||
|
|
||||||
@@ -296,6 +294,24 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
val isReaderKeepScreenOn: Boolean
|
val isReaderKeepScreenOn: Boolean
|
||||||
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
|
get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true)
|
||||||
|
|
||||||
|
var readerColorFilter: ReaderColorFilter?
|
||||||
|
get() {
|
||||||
|
val brightness = prefs.getFloat(KEY_CF_BRIGHTNESS, ReaderColorFilter.EMPTY.brightness)
|
||||||
|
val contrast = prefs.getFloat(KEY_CF_CONTRAST, ReaderColorFilter.EMPTY.contrast)
|
||||||
|
val inverted = prefs.getBoolean(KEY_CF_INVERTED, ReaderColorFilter.EMPTY.isInverted)
|
||||||
|
val grayscale = prefs.getBoolean(KEY_CF_GRAYSCALE, ReaderColorFilter.EMPTY.isGrayscale)
|
||||||
|
return ReaderColorFilter(brightness, contrast, inverted, grayscale).takeUnless { it.isEmpty }
|
||||||
|
}
|
||||||
|
set(value) {
|
||||||
|
prefs.edit {
|
||||||
|
val cf = value ?: ReaderColorFilter.EMPTY
|
||||||
|
putFloat(KEY_CF_BRIGHTNESS, cf.brightness)
|
||||||
|
putFloat(KEY_CF_CONTRAST, cf.contrast)
|
||||||
|
putBoolean(KEY_CF_INVERTED, cf.isInverted)
|
||||||
|
putBoolean(KEY_CF_GRAYSCALE, cf.isGrayscale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val isImagesProxyEnabled: Boolean
|
val isImagesProxyEnabled: Boolean
|
||||||
get() = prefs.getBoolean(KEY_IMAGES_PROXY, false)
|
get() = prefs.getBoolean(KEY_IMAGES_PROXY, false)
|
||||||
|
|
||||||
@@ -495,7 +511,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_SHIKIMORI = "shikimori"
|
const val KEY_SHIKIMORI = "shikimori"
|
||||||
const val KEY_ANILIST = "anilist"
|
const val KEY_ANILIST = "anilist"
|
||||||
const val KEY_MAL = "mal"
|
const val KEY_MAL = "mal"
|
||||||
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
|
|
||||||
const val KEY_DOWNLOADS_WIFI = "downloads_wifi"
|
const val KEY_DOWNLOADS_WIFI = "downloads_wifi"
|
||||||
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
|
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
|
||||||
const val KEY_DOH = "doh"
|
const val KEY_DOH = "doh"
|
||||||
@@ -539,6 +554,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
|
|||||||
const val KEY_32BIT_COLOR = "enhanced_colors"
|
const val KEY_32BIT_COLOR = "enhanced_colors"
|
||||||
const val KEY_SOURCES_ORDER = "sources_sort_order"
|
const val KEY_SOURCES_ORDER = "sources_sort_order"
|
||||||
const val KEY_SOURCES_CATALOG = "sources_catalog"
|
const val KEY_SOURCES_CATALOG = "sources_catalog"
|
||||||
|
const val KEY_CF_BRIGHTNESS = "cf_brightness"
|
||||||
|
const val KEY_CF_CONTRAST = "cf_contrast"
|
||||||
|
const val KEY_CF_INVERTED = "cf_inverted"
|
||||||
|
const val KEY_CF_GRAYSCALE = "cf_grayscale"
|
||||||
|
const val KEY_IGNORE_DOZE = "ignore_dose"
|
||||||
|
|
||||||
// About
|
// About
|
||||||
const val KEY_APP_UPDATE = "app_update"
|
const val KEY_APP_UPDATE = "app_update"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
|
|||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
|
||||||
private const val KEY_SORT_ORDER = "sort_order"
|
private const val KEY_SORT_ORDER = "sort_order"
|
||||||
|
private const val KEY_SLOWDOWN = "slowdown"
|
||||||
|
|
||||||
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
|
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
|
||||||
|
|
||||||
@@ -20,6 +21,9 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
|
|||||||
get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java)
|
get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java)
|
||||||
set(value) = prefs.edit { putEnumValue(KEY_SORT_ORDER, value) }
|
set(value) = prefs.edit { putEnumValue(KEY_SORT_ORDER, value) }
|
||||||
|
|
||||||
|
val isSlowdownEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(KEY_SLOWDOWN, false)
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
override fun <T> get(key: ConfigKey<T>): T {
|
override fun <T> get(key: ConfigKey<T>): T {
|
||||||
return when (key) {
|
return when (key) {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui
|
||||||
|
|
||||||
|
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.AdapterDelegate
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.LinkedList
|
||||||
|
|
||||||
|
open class ReorderableListAdapter<T : ListModel> : ListDelegationAdapter<List<T>>(), FlowCollector<List<T>?> {
|
||||||
|
|
||||||
|
private val listListeners = LinkedList<ListListener<T>>()
|
||||||
|
|
||||||
|
override suspend fun emit(value: List<T>?) {
|
||||||
|
val oldList = items.orEmpty()
|
||||||
|
val newList = value.orEmpty()
|
||||||
|
val diffResult = withContext(Dispatchers.Default) {
|
||||||
|
val diffCallback = DiffCallback(oldList, newList)
|
||||||
|
DiffUtil.calculateDiff(diffCallback)
|
||||||
|
}
|
||||||
|
super.setItems(newList)
|
||||||
|
diffResult.dispatchUpdatesTo(this)
|
||||||
|
listListeners.forEach { it.onCurrentListChanged(oldList, newList) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Use emit() to dispatch list updates", level = DeprecationLevel.ERROR)
|
||||||
|
override fun setItems(items: List<T>?) {
|
||||||
|
super.setItems(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reorderItems(oldPos: Int, newPos: Int) {
|
||||||
|
Collections.swap(items ?: return, oldPos, newPos)
|
||||||
|
notifyItemMoved(oldPos, newPos)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addDelegate(type: ListItemType, delegate: AdapterDelegate<List<T>>): ReorderableListAdapter<T> {
|
||||||
|
delegatesManager.addDelegate(type.ordinal, delegate)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addListListener(listListener: ListListener<T>) {
|
||||||
|
listListeners.add(listListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeListListener(listListener: ListListener<T>) {
|
||||||
|
listListeners.remove(listListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected class DiffCallback<T : ListModel>(
|
||||||
|
val oldList: List<T>,
|
||||||
|
val newList: List<T>,
|
||||||
|
) : DiffUtil.Callback() {
|
||||||
|
|
||||||
|
override fun getOldListSize(): Int = oldList.size
|
||||||
|
|
||||||
|
override fun getNewListSize(): Int = newList.size
|
||||||
|
|
||||||
|
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||||
|
val oldItem = oldList[oldItemPosition]
|
||||||
|
val newItem = newList[newItemPosition]
|
||||||
|
return newItem.areItemsTheSame(oldItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
|
||||||
|
val oldItem = oldList[oldItemPosition]
|
||||||
|
val newItem = newList[newItemPosition]
|
||||||
|
return newItem == oldItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.drawable
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
|
||||||
import android.graphics.Canvas
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.graphics.ColorFilter
|
|
||||||
import android.graphics.Paint
|
|
||||||
import android.graphics.PixelFormat
|
|
||||||
import android.graphics.Typeface
|
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import android.os.Build
|
|
||||||
import android.text.Layout
|
|
||||||
import android.text.StaticLayout
|
|
||||||
import android.text.TextPaint
|
|
||||||
import androidx.annotation.ColorInt
|
|
||||||
import androidx.annotation.Px
|
|
||||||
import androidx.annotation.StyleRes
|
|
||||||
import androidx.core.graphics.withTranslation
|
|
||||||
import com.google.android.material.resources.TextAppearance
|
|
||||||
import com.google.android.material.resources.TextAppearanceFontCallback
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
|
||||||
|
|
||||||
class TextDrawable(
|
|
||||||
val text: CharSequence,
|
|
||||||
) : Drawable() {
|
|
||||||
|
|
||||||
private val paint = TextPaint(Paint.ANTI_ALIAS_FLAG)
|
|
||||||
private var cachedLayout: StaticLayout? = null
|
|
||||||
|
|
||||||
@SuppressLint("RestrictedApi")
|
|
||||||
constructor(context: Context, text: CharSequence, @StyleRes textAppearanceId: Int) : this(text) {
|
|
||||||
val ta = TextAppearance(context, textAppearanceId)
|
|
||||||
paint.color = ta.textColor?.defaultColor ?: context.getThemeColor(android.R.attr.textColorPrimary, Color.BLACK)
|
|
||||||
paint.typeface = ta.fallbackFont
|
|
||||||
ta.getFontAsync(
|
|
||||||
context, paint,
|
|
||||||
object : TextAppearanceFontCallback() {
|
|
||||||
override fun onFontRetrieved(typeface: Typeface?, fontResolvedSynchronously: Boolean) = Unit
|
|
||||||
override fun onFontRetrievalFailed(reason: Int) = Unit
|
|
||||||
},
|
|
||||||
)
|
|
||||||
paint.letterSpacing = ta.letterSpacing
|
|
||||||
}
|
|
||||||
|
|
||||||
var alignment = Layout.Alignment.ALIGN_NORMAL
|
|
||||||
|
|
||||||
var lineSpacingMultiplier = 1f
|
|
||||||
|
|
||||||
@Px
|
|
||||||
var lineSpacingExtra = 0f
|
|
||||||
|
|
||||||
@get:ColorInt
|
|
||||||
var textColor: Int
|
|
||||||
get() = paint.color
|
|
||||||
set(@ColorInt value) {
|
|
||||||
paint.color = value
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun draw(canvas: Canvas) {
|
|
||||||
val b = bounds
|
|
||||||
if (b.isEmpty) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
canvas.withTranslation(x = b.left.toFloat(), y = b.top.toFloat()) {
|
|
||||||
obtainLayout().draw(canvas)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setAlpha(alpha: Int) {
|
|
||||||
paint.alpha = alpha
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setColorFilter(colorFilter: ColorFilter?) {
|
|
||||||
paint.setColorFilter(colorFilter)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("DeprecatedCallableAddReplaceWith")
|
|
||||||
@Deprecated("Deprecated in Java")
|
|
||||||
override fun getOpacity(): Int = PixelFormat.TRANSLUCENT
|
|
||||||
|
|
||||||
private fun obtainLayout(): StaticLayout {
|
|
||||||
val width = bounds.width()
|
|
||||||
cachedLayout?.let {
|
|
||||||
if (it.width == width) {
|
|
||||||
return it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
||||||
StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
|
|
||||||
.setAlignment(alignment)
|
|
||||||
.setLineSpacing(lineSpacingExtra, lineSpacingMultiplier)
|
|
||||||
.setIncludePad(true)
|
|
||||||
.build()
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
StaticLayout(text, paint, width, alignment, lineSpacingMultiplier, lineSpacingExtra, true)
|
|
||||||
}.also { cachedLayout = it }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.list
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.Parcelable
|
|
||||||
import android.util.SparseArray
|
|
||||||
import androidx.core.os.BundleCompat
|
|
||||||
import androidx.core.view.doOnNextLayout
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import java.util.Collections
|
|
||||||
import java.util.WeakHashMap
|
|
||||||
|
|
||||||
class NestedScrollStateHandle(
|
|
||||||
savedInstanceState: Bundle?,
|
|
||||||
private val key: String,
|
|
||||||
) {
|
|
||||||
|
|
||||||
private val storage: SparseArray<Parcelable?> = savedInstanceState?.let {
|
|
||||||
BundleCompat.getSparseParcelableArray(it, key, Parcelable::class.java)
|
|
||||||
} ?: SparseArray<Parcelable?>()
|
|
||||||
private val controllers = Collections.newSetFromMap<Controller>(WeakHashMap())
|
|
||||||
|
|
||||||
fun attach(recycler: RecyclerView) = Controller(recycler).also(controllers::add)
|
|
||||||
|
|
||||||
fun onSaveInstanceState(outState: Bundle) {
|
|
||||||
controllers.forEach {
|
|
||||||
it.saveState()
|
|
||||||
}
|
|
||||||
outState.putSparseParcelableArray(key, storage)
|
|
||||||
}
|
|
||||||
|
|
||||||
inner class Controller(
|
|
||||||
private val recycler: RecyclerView
|
|
||||||
) {
|
|
||||||
|
|
||||||
private var lastPosition: Int = -1
|
|
||||||
|
|
||||||
fun onBind(position: Int) {
|
|
||||||
if (position != lastPosition) {
|
|
||||||
saveState()
|
|
||||||
lastPosition = position
|
|
||||||
storage[position]?.let {
|
|
||||||
restoreState(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onRecycled() {
|
|
||||||
saveState()
|
|
||||||
lastPosition = -1
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveState() {
|
|
||||||
if (lastPosition != -1) {
|
|
||||||
storage[lastPosition] = recycler.layoutManager?.onSaveInstanceState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun restoreState(state: Parcelable) {
|
|
||||||
recycler.doOnNextLayout {
|
|
||||||
recycler.layoutManager?.onRestoreInstanceState(state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,237 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.list
|
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.view.ActionMode
|
|
||||||
import androidx.collection.ArrayMap
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import androidx.savedstate.SavedStateRegistry
|
|
||||||
import androidx.savedstate.SavedStateRegistryOwner
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.decor.AbstractSelectionItemDecoration
|
|
||||||
import kotlin.coroutines.EmptyCoroutineContext
|
|
||||||
|
|
||||||
private const val PROVIDER_NAME = "selection_decoration_sectioned"
|
private const val PROVIDER_NAME = "selection_decoration_sectioned"
|
||||||
|
|
||||||
class SectionedSelectionController<T : Any>(
|
|
||||||
private val activity: Activity,
|
|
||||||
private val owner: SavedStateRegistryOwner,
|
|
||||||
private val callback: Callback<T>,
|
|
||||||
) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider {
|
|
||||||
|
|
||||||
private var actionMode: ActionMode? = null
|
|
||||||
|
|
||||||
private var pendingData: MutableMap<String, Collection<Long>>? = null
|
|
||||||
private val decorations = ArrayMap<T, AbstractSelectionItemDecoration>()
|
|
||||||
|
|
||||||
val count: Int
|
|
||||||
get() = decorations.values.sumOf { it.checkedItemsCount }
|
|
||||||
|
|
||||||
init {
|
|
||||||
owner.lifecycle.addObserver(StateEventObserver())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun snapshot(): Map<T, Set<Long>> {
|
|
||||||
return decorations.mapValues { it.value.checkedItemsIds.toSet() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun peekCheckedIds(): Map<T, Set<Long>> {
|
|
||||||
return decorations.mapValues { it.value.checkedItemsIds }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clear() {
|
|
||||||
decorations.values.forEach {
|
|
||||||
it.clearSelection()
|
|
||||||
}
|
|
||||||
notifySelectionChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun attachToRecyclerView(section: T, recyclerView: RecyclerView) {
|
|
||||||
val decoration = getDecoration(section)
|
|
||||||
val pendingIds = pendingData?.remove(section.toString())
|
|
||||||
if (!pendingIds.isNullOrEmpty()) {
|
|
||||||
decoration.checkAll(pendingIds)
|
|
||||||
startActionMode()
|
|
||||||
notifySelectionChanged()
|
|
||||||
}
|
|
||||||
var shouldAddDecoration = true
|
|
||||||
for (i in (0 until recyclerView.itemDecorationCount).reversed()) {
|
|
||||||
val decor = recyclerView.getItemDecorationAt(i)
|
|
||||||
if (decor === decoration) {
|
|
||||||
shouldAddDecoration = false
|
|
||||||
break
|
|
||||||
} else if (decor.javaClass == decoration.javaClass) {
|
|
||||||
recyclerView.removeItemDecorationAt(i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (shouldAddDecoration) {
|
|
||||||
recyclerView.addItemDecoration(decoration)
|
|
||||||
}
|
|
||||||
if (pendingData?.isEmpty() == true) {
|
|
||||||
pendingData = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun saveState(): Bundle {
|
|
||||||
val bundle = Bundle(decorations.size)
|
|
||||||
for ((k, v) in decorations) {
|
|
||||||
bundle.putLongArray(k.toString(), v.checkedItemsIds.toLongArray())
|
|
||||||
}
|
|
||||||
return bundle
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onItemClick(section: T, id: Long): Boolean {
|
|
||||||
val decoration = getDecoration(section)
|
|
||||||
if (isInSelectionMode()) {
|
|
||||||
decoration.toggleItemChecked(id)
|
|
||||||
if (isInSelectionMode()) {
|
|
||||||
actionMode?.invalidate()
|
|
||||||
} else {
|
|
||||||
actionMode?.finish()
|
|
||||||
}
|
|
||||||
notifySelectionChanged()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onItemLongClick(section: T, id: Long): Boolean {
|
|
||||||
val decoration = getDecoration(section)
|
|
||||||
startActionMode()
|
|
||||||
return actionMode?.also {
|
|
||||||
decoration.setItemIsChecked(id, true)
|
|
||||||
notifySelectionChanged()
|
|
||||||
} != null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSectionCount(section: T): Int {
|
|
||||||
return decorations[section]?.checkedItemsCount ?: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addToSelection(section: T, ids: Collection<Long>): Boolean {
|
|
||||||
val decoration = getDecoration(section)
|
|
||||||
startActionMode()
|
|
||||||
return actionMode?.also {
|
|
||||||
decoration.checkAll(ids)
|
|
||||||
notifySelectionChanged()
|
|
||||||
} != null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearSelection(section: T) {
|
|
||||||
decorations[section]?.clearSelection() ?: return
|
|
||||||
notifySelectionChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
|
||||||
return callback.onCreateActionMode(this, mode, menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
|
|
||||||
return callback.onPrepareActionMode(this, mode, menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
|
||||||
return callback.onActionItemClicked(this, mode, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyActionMode(mode: ActionMode) {
|
|
||||||
callback.onDestroyActionMode(this, mode)
|
|
||||||
clear()
|
|
||||||
actionMode = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startActionMode() {
|
|
||||||
if (actionMode == null) {
|
|
||||||
actionMode = (activity as? AppCompatActivity)?.startSupportActionMode(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isInSelectionMode(): Boolean {
|
|
||||||
return decorations.values.any { x -> x.checkedItemsCount > 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun notifySelectionChanged() {
|
|
||||||
val count = this.count
|
|
||||||
callback.onSelectionChanged(this, count)
|
|
||||||
if (count == 0) {
|
|
||||||
actionMode?.finish()
|
|
||||||
} else {
|
|
||||||
actionMode?.invalidate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun restoreState(ids: MutableMap<String, Collection<Long>>) {
|
|
||||||
if (ids.isEmpty() || isInSelectionMode()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for ((k, v) in decorations) {
|
|
||||||
val items = ids.remove(k.toString())
|
|
||||||
if (!items.isNullOrEmpty()) {
|
|
||||||
v.checkAll(items)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pendingData = ids
|
|
||||||
if (isInSelectionMode()) {
|
|
||||||
startActionMode()
|
|
||||||
notifySelectionChanged()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getDecoration(section: T): AbstractSelectionItemDecoration {
|
|
||||||
return decorations.getOrPut(section) {
|
|
||||||
callback.onCreateItemDecoration(this, section)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Callback<T : Any> {
|
|
||||||
|
|
||||||
fun onSelectionChanged(controller: SectionedSelectionController<T>, count: Int)
|
|
||||||
|
|
||||||
fun onCreateActionMode(controller: SectionedSelectionController<T>, mode: ActionMode, menu: Menu): Boolean
|
|
||||||
|
|
||||||
fun onPrepareActionMode(controller: SectionedSelectionController<T>, mode: ActionMode, menu: Menu): Boolean {
|
|
||||||
mode.title = controller.count.toString()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onDestroyActionMode(controller: SectionedSelectionController<T>, mode: ActionMode) = Unit
|
|
||||||
|
|
||||||
fun onActionItemClicked(
|
|
||||||
controller: SectionedSelectionController<T>,
|
|
||||||
mode: ActionMode,
|
|
||||||
item: MenuItem,
|
|
||||||
): Boolean
|
|
||||||
|
|
||||||
fun onCreateItemDecoration(
|
|
||||||
controller: SectionedSelectionController<T>,
|
|
||||||
section: T,
|
|
||||||
): AbstractSelectionItemDecoration
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class StateEventObserver : LifecycleEventObserver {
|
|
||||||
|
|
||||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
|
||||||
if (event == Lifecycle.Event.ON_CREATE) {
|
|
||||||
val registry = owner.savedStateRegistry
|
|
||||||
registry.registerSavedStateProvider(PROVIDER_NAME, this@SectionedSelectionController)
|
|
||||||
val state = registry.consumeRestoredStateForKey(PROVIDER_NAME)
|
|
||||||
if (state != null) {
|
|
||||||
Dispatchers.Main.dispatch(EmptyCoroutineContext) { // == Handler.post
|
|
||||||
if (source.lifecycle.currentState.isAtLeast(Lifecycle.State.CREATED)) {
|
|
||||||
restoreState(
|
|
||||||
state.keySet()
|
|
||||||
.associateWithTo(HashMap()) { state.getLongArray(it)?.toList().orEmpty() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ import android.view.MotionEvent
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.*
|
import android.widget.*
|
||||||
import androidx.annotation.*
|
import androidx.annotation.AttrRes
|
||||||
|
import androidx.annotation.ColorInt
|
||||||
|
import androidx.annotation.DimenRes
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.annotation.Px
|
||||||
|
import androidx.annotation.StyleableRes
|
||||||
import androidx.constraintlayout.widget.ConstraintLayout
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import androidx.constraintlayout.widget.ConstraintSet
|
import androidx.constraintlayout.widget.ConstraintSet
|
||||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
@@ -131,19 +136,19 @@ class FastScroller @JvmOverloads constructor(
|
|||||||
|
|
||||||
var showTrack = false
|
var showTrack = false
|
||||||
|
|
||||||
context.withStyledAttributes(attrs, R.styleable.FastScroller, defStyleAttr) {
|
context.withStyledAttributes(attrs, R.styleable.FastScrollRecyclerView, defStyleAttr) {
|
||||||
bubbleColor = getColor(R.styleable.FastScroller_bubbleColor, bubbleColor)
|
bubbleColor = getColor(R.styleable.FastScrollRecyclerView_bubbleColor, bubbleColor)
|
||||||
handleColor = getColor(R.styleable.FastScroller_thumbColor, handleColor)
|
handleColor = getColor(R.styleable.FastScrollRecyclerView_thumbColor, handleColor)
|
||||||
trackColor = getColor(R.styleable.FastScroller_trackColor, trackColor)
|
trackColor = getColor(R.styleable.FastScrollRecyclerView_trackColor, trackColor)
|
||||||
textColor = getColor(R.styleable.FastScroller_bubbleTextColor, textColor)
|
textColor = getColor(R.styleable.FastScrollRecyclerView_bubbleTextColor, textColor)
|
||||||
hideScrollbar = getBoolean(R.styleable.FastScroller_hideScrollbar, hideScrollbar)
|
hideScrollbar = getBoolean(R.styleable.FastScrollRecyclerView_hideScrollbar, hideScrollbar)
|
||||||
showBubble = getBoolean(R.styleable.FastScroller_showBubble, showBubble)
|
showBubble = getBoolean(R.styleable.FastScrollRecyclerView_showBubble, showBubble)
|
||||||
showBubbleAlways = getBoolean(R.styleable.FastScroller_showBubbleAlways, showBubbleAlways)
|
showBubbleAlways = getBoolean(R.styleable.FastScrollRecyclerView_showBubbleAlways, showBubbleAlways)
|
||||||
showTrack = getBoolean(R.styleable.FastScroller_showTrack, showTrack)
|
showTrack = getBoolean(R.styleable.FastScrollRecyclerView_showTrack, showTrack)
|
||||||
bubbleSize = getBubbleSize(R.styleable.FastScroller_bubbleSize, BubbleSize.NORMAL)
|
bubbleSize = getBubbleSize(R.styleable.FastScrollRecyclerView_bubbleSize, BubbleSize.NORMAL)
|
||||||
val textSize = getDimension(R.styleable.FastScroller_bubbleTextSize, bubbleSize.textSize)
|
val textSize = getDimension(R.styleable.FastScrollRecyclerView_bubbleTextSize, bubbleSize.textSize)
|
||||||
binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
|
binding.bubble.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
|
||||||
offset = getDimensionPixelOffset(R.styleable.FastScroller_scrollerOffset, offset)
|
offset = getDimensionPixelOffset(R.styleable.FastScrollRecyclerView_scrollerOffset, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
setTrackColor(trackColor)
|
setTrackColor(trackColor)
|
||||||
|
|||||||
@@ -79,7 +79,11 @@ sealed class DateTimeAgo {
|
|||||||
private val day = date.daysDiff(0)
|
private val day = date.daysDiff(0)
|
||||||
|
|
||||||
override fun format(resources: Resources): String {
|
override fun format(resources: Resources): String {
|
||||||
return date.format("d MMMM")
|
return if (date.time == 0L) {
|
||||||
|
resources.getString(R.string.unknown)
|
||||||
|
} else {
|
||||||
|
date.format("d MMMM")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.widgets
|
package org.koitharu.kotatsu.core.ui.widgets
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.ColorStateList
|
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.View.OnClickListener
|
import android.view.View.OnClickListener
|
||||||
import androidx.annotation.ColorRes
|
import androidx.annotation.ColorRes
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.core.content.res.getColorStateListOrThrow
|
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
import com.google.android.material.chip.Chip
|
import com.google.android.material.chip.Chip
|
||||||
import com.google.android.material.chip.ChipDrawable
|
import com.google.android.material.chip.ChipDrawable
|
||||||
import com.google.android.material.chip.ChipGroup
|
import com.google.android.material.chip.ChipGroup
|
||||||
|
import com.google.android.material.theme.overlay.MaterialThemeOverlay
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.util.ext.castOrNull
|
import org.koitharu.kotatsu.core.util.ext.castOrNull
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
|
|
||||||
class ChipsView @JvmOverloads constructor(
|
class ChipsView @JvmOverloads constructor(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -31,9 +27,7 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
private val chipOnCloseListener = OnClickListener {
|
private val chipOnCloseListener = OnClickListener {
|
||||||
onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag)
|
onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag)
|
||||||
}
|
}
|
||||||
private val defaultChipStrokeColor: ColorStateList
|
private val chipStyle: Int
|
||||||
private val defaultChipTextColor: ColorStateList
|
|
||||||
private val defaultChipIconTint: ColorStateList
|
|
||||||
var onChipClickListener: OnChipClickListener? = null
|
var onChipClickListener: OnChipClickListener? = null
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
@@ -48,12 +42,16 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@SuppressLint("CustomViewStyleable")
|
chipStyle = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0).use {
|
||||||
val a = context.obtainStyledAttributes(null, materialR.styleable.Chip, 0, R.style.Widget_Kotatsu_Chip)
|
it.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip)
|
||||||
defaultChipStrokeColor = a.getColorStateListOrThrow(materialR.styleable.Chip_chipStrokeColor)
|
}
|
||||||
defaultChipTextColor = a.getColorStateListOrThrow(materialR.styleable.Chip_android_textColor)
|
if (isInEditMode) {
|
||||||
defaultChipIconTint = a.getColorStateListOrThrow(materialR.styleable.Chip_chipIconTint)
|
setChips(
|
||||||
a.recycle()
|
List(5) {
|
||||||
|
ChipModel(0, "Chip $it", 0, isCheckable = false, isChecked = false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun requestLayout() {
|
override fun requestLayout() {
|
||||||
@@ -91,15 +89,6 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
|
|
||||||
private fun bindChip(chip: Chip, model: ChipModel) {
|
private fun bindChip(chip: Chip, model: ChipModel) {
|
||||||
chip.text = model.title
|
chip.text = model.title
|
||||||
val tint = if (model.tint == 0) {
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
ContextCompat.getColorStateList(context, model.tint)
|
|
||||||
}
|
|
||||||
chip.chipIconTint = tint ?: defaultChipIconTint
|
|
||||||
chip.checkedIconTint = tint ?: defaultChipIconTint
|
|
||||||
chip.chipStrokeColor = tint ?: defaultChipStrokeColor
|
|
||||||
chip.setTextColor(tint ?: defaultChipTextColor)
|
|
||||||
chip.isClickable = onChipClickListener != null || model.isCheckable
|
chip.isClickable = onChipClickListener != null || model.isCheckable
|
||||||
chip.isCheckable = model.isCheckable
|
chip.isCheckable = model.isCheckable
|
||||||
if (model.icon == 0) {
|
if (model.icon == 0) {
|
||||||
@@ -114,13 +103,14 @@ class ChipsView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun addChip(): Chip {
|
private fun addChip(): Chip {
|
||||||
val chip = Chip(context)
|
val themedContext = MaterialThemeOverlay.wrap(context, null, 0, chipStyle)
|
||||||
val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip)
|
val chip = Chip(themedContext, null)
|
||||||
|
val drawable = ChipDrawable.createFromAttributes(themedContext, null, 0, chipStyle)
|
||||||
chip.setChipDrawable(drawable)
|
chip.setChipDrawable(drawable)
|
||||||
chip.isCheckedIconVisible = true
|
chip.isCheckedIconVisible = true
|
||||||
chip.isChipIconVisible = false
|
chip.isChipIconVisible = false
|
||||||
chip.setCheckedIconResource(R.drawable.ic_check)
|
chip.setCheckedIconResource(R.drawable.ic_check)
|
||||||
chip.checkedIconTint = defaultChipIconTint
|
// chip.checkedIconTint = chip.ic
|
||||||
chip.isCloseIconVisible = onChipCloseClickListener != null
|
chip.isCloseIconVisible = onChipCloseClickListener != null
|
||||||
chip.setOnCloseIconClickListener(chipOnCloseListener)
|
chip.setOnCloseIconClickListener(chipOnCloseListener)
|
||||||
chip.setEnsureMinTouchTargetSize(false)
|
chip.setEnsureMinTouchTargetSize(false)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.widgets
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import androidx.core.content.withStyledAttributes
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
|
||||||
|
class NestedRecyclerView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null
|
||||||
|
) : RecyclerView(context, attrs) {
|
||||||
|
|
||||||
|
private var maxHeight: Int = 0
|
||||||
|
|
||||||
|
init {
|
||||||
|
context.withStyledAttributes(attrs, R.styleable.NestedRecyclerView) {
|
||||||
|
maxHeight = getDimensionPixelSize(R.styleable.NestedRecyclerView_maxHeight, maxHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
|
override fun onTouchEvent(e: MotionEvent?): Boolean {
|
||||||
|
if (e?.actionMasked == MotionEvent.ACTION_UP) {
|
||||||
|
requestDisallowInterceptTouchEvent(false)
|
||||||
|
} else {
|
||||||
|
requestDisallowInterceptTouchEvent(true)
|
||||||
|
}
|
||||||
|
return super.onTouchEvent(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMeasure(widthSpec: Int, heightSpec: Int) {
|
||||||
|
super.onMeasure(
|
||||||
|
widthSpec,
|
||||||
|
if (maxHeight == 0) {
|
||||||
|
heightSpec
|
||||||
|
} else {
|
||||||
|
MeasureSpec.makeMeasureSpec(maxHeight, MeasureSpec.AT_MOST)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,6 @@ import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList
|
|||||||
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
|
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
|
||||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||||
import org.koitharu.kotatsu.databinding.ViewTipBinding
|
import org.koitharu.kotatsu.databinding.ViewTipBinding
|
||||||
import com.google.android.material.R as materialR
|
|
||||||
|
|
||||||
class TipView @JvmOverloads constructor(
|
class TipView @JvmOverloads constructor(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
|||||||
@@ -4,23 +4,20 @@ import androidx.core.os.LocaleListCompat
|
|||||||
import org.koitharu.kotatsu.core.util.ext.map
|
import org.koitharu.kotatsu.core.util.ext.map
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class LocaleComparator : Comparator<Locale?> {
|
class LocaleComparator : Comparator<Locale> {
|
||||||
|
|
||||||
private val deviceLocales = LocaleListCompat.getAdjustedDefault()//LocaleManagerCompat.getSystemLocales(context)
|
private val deviceLocales = LocaleListCompat.getAdjustedDefault()//LocaleManagerCompat.getSystemLocales(context)
|
||||||
.map { it.language }
|
.map { it.language }
|
||||||
.distinct()
|
.distinct()
|
||||||
|
|
||||||
override fun compare(a: Locale?, b: Locale?): Int {
|
override fun compare(a: Locale, b: Locale): Int {
|
||||||
return if (a === b) {
|
val indexA = deviceLocales.indexOf(a.language)
|
||||||
0
|
val indexB = deviceLocales.indexOf(b.language)
|
||||||
} else {
|
return when {
|
||||||
val indexA = if (a == null) -1 else deviceLocales.indexOf(a.language)
|
indexA < 0 && indexB < 0 -> compareValues(a.language, b.language)
|
||||||
val indexB = if (b == null) -1 else deviceLocales.indexOf(b.language)
|
indexA < 0 -> 1
|
||||||
if (indexA < 0 && indexB < 0) {
|
indexB < 0 -> -1
|
||||||
compareValues(a?.language, b?.language)
|
else -> compareValues(indexA, indexB)
|
||||||
} else {
|
|
||||||
-2 - (indexA - indexB)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ class ViewBadge(
|
|||||||
get() = badgeDrawable?.number ?: 0
|
get() = badgeDrawable?.number ?: 0
|
||||||
set(value) {
|
set(value) {
|
||||||
val badge = badgeDrawable ?: initBadge()
|
val badge = badgeDrawable ?: initBadge()
|
||||||
badge.number = value
|
if (maxCharacterCount != 0) {
|
||||||
|
badge.number = value
|
||||||
|
} else {
|
||||||
|
badge.clearNumber()
|
||||||
|
}
|
||||||
badge.isVisible = value > 0
|
badge.isVisible = value > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +55,13 @@ class ViewBadge(
|
|||||||
|
|
||||||
fun setMaxCharacterCount(value: Int) {
|
fun setMaxCharacterCount(value: Int) {
|
||||||
maxCharacterCount = value
|
maxCharacterCount = value
|
||||||
badgeDrawable?.maxCharacterCount = value
|
badgeDrawable?.let {
|
||||||
|
if (value == 0) {
|
||||||
|
it.clearNumber()
|
||||||
|
} else {
|
||||||
|
it.maxCharacterCount = value
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initBadge(): BadgeDrawable {
|
private fun initBadge(): BadgeDrawable {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.core.util
|
package org.koitharu.kotatsu.core.util
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import androidx.lifecycle.asFlow
|
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.WorkQuery
|
import androidx.work.WorkQuery
|
||||||
@@ -27,8 +26,7 @@ class WorkServiceStopHelper(
|
|||||||
fun setup() {
|
fun setup() {
|
||||||
processLifecycleScope.launch(Dispatchers.Default) {
|
processLifecycleScope.launch(Dispatchers.Default) {
|
||||||
workManagerProvider.get()
|
workManagerProvider.get()
|
||||||
.getWorkInfosLiveData(WorkQuery.fromStates(WorkInfo.State.RUNNING))
|
.getWorkInfosFlow(WorkQuery.fromStates(WorkInfo.State.RUNNING))
|
||||||
.asFlow()
|
|
||||||
.map { it.isEmpty() }
|
.map { it.isEmpty() }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collectLatest {
|
.collectLatest {
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ import org.koitharu.kotatsu.core.exceptions.SyncApiException
|
|||||||
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
|
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
|
||||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||||
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
|
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
|
||||||
|
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED
|
||||||
|
import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_STATES_NOT_SUPPORTED
|
||||||
|
import org.koitharu.kotatsu.parsers.ErrorMessages.SEARCH_NOT_SUPPORTED
|
||||||
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
|
||||||
import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException
|
import org.koitharu.kotatsu.parsers.exception.ContentUnavailableException
|
||||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||||
@@ -28,9 +33,6 @@ import java.net.UnknownHostException
|
|||||||
|
|
||||||
private const val MSG_NO_SPACE_LEFT = "No space left on device"
|
private const val MSG_NO_SPACE_LEFT = "No space left on device"
|
||||||
private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
|
private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported"
|
||||||
private const val MULTIPLE_GENRES_NOT_SUPPORTED = "Multiple genres are not supported by this source"
|
|
||||||
private const val MULTIPLE_STATES_NOT_SUPPORTED = "Multiple states are not supported by this source"
|
|
||||||
private const val SEARCH_NOT_SUPPORTED = "Search is not supported by this source"
|
|
||||||
|
|
||||||
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
|
fun Throwable.getDisplayMessage(resources: Resources): String = when (this) {
|
||||||
is AuthRequiredException -> resources.getString(R.string.auth_required)
|
is AuthRequiredException -> resources.getString(R.string.auth_required)
|
||||||
@@ -85,9 +87,11 @@ private fun getDisplayMessage(msg: String?, resources: Resources): String? = whe
|
|||||||
msg.isNullOrEmpty() -> null
|
msg.isNullOrEmpty() -> null
|
||||||
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
|
msg.contains(MSG_NO_SPACE_LEFT) -> resources.getString(R.string.error_no_space_left)
|
||||||
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
|
msg.contains(IMAGE_FORMAT_NOT_SUPPORTED) -> resources.getString(R.string.error_corrupted_file)
|
||||||
msg == MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
|
msg == FILTER_MULTIPLE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_not_supported)
|
||||||
msg == MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
|
msg == FILTER_MULTIPLE_STATES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_states_not_supported)
|
||||||
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
|
msg == SEARCH_NOT_SUPPORTED -> resources.getString(R.string.error_search_not_supported)
|
||||||
|
msg == FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_locale_genre_not_supported)
|
||||||
|
msg == FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_filter_states_genre_not_supported)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ fun RecyclerView.invalidateNestedItemDecorations() {
|
|||||||
val View.parentView: ViewGroup?
|
val View.parentView: ViewGroup?
|
||||||
get() = parent as? ViewGroup
|
get() = parent as? ViewGroup
|
||||||
|
|
||||||
|
@Suppress("UnusedReceiverParameter")
|
||||||
fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int {
|
fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int {
|
||||||
var result: Int
|
var result: Int
|
||||||
val specMode = MeasureSpec.getMode(measureSpec)
|
val specMode = MeasureSpec.getMode(measureSpec)
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package org.koitharu.kotatsu.core.util.ext
|
package org.koitharu.kotatsu.core.util.ext
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import androidx.work.Data
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.WorkQuery
|
import androidx.work.WorkQuery
|
||||||
import androidx.work.WorkRequest
|
import androidx.work.WorkRequest
|
||||||
import androidx.work.await
|
import androidx.work.await
|
||||||
import androidx.work.impl.WorkManagerImpl
|
import androidx.work.impl.WorkManagerImpl
|
||||||
|
import androidx.work.impl.model.WorkSpec
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
@@ -69,5 +71,24 @@ suspend fun WorkManager.awaitUpdateWork(request: WorkRequest): WorkManager.Updat
|
|||||||
return updateWork(request).await()
|
return updateWork(request).await()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("RestrictedApi")
|
||||||
|
suspend fun WorkManager.getWorkSpec(id: UUID): WorkSpec? = suspendCoroutine { cont ->
|
||||||
|
workManagerImpl.workTaskExecutor.executeOnTaskThread {
|
||||||
|
try {
|
||||||
|
val spec = workManagerImpl.workDatabase.workSpecDao().getWorkSpec(id.toString())
|
||||||
|
cont.resume(spec)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
cont.resumeWithException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@SuppressLint("RestrictedApi")
|
||||||
|
suspend fun WorkManager.getWorkInputData(id: UUID): Data? = getWorkSpec(id)?.input
|
||||||
|
|
||||||
|
val Data.isEmpty: Boolean
|
||||||
|
get() = this == Data.EMPTY
|
||||||
|
|
||||||
private val WorkManager.workManagerImpl
|
private val WorkManager.workManagerImpl
|
||||||
@SuppressLint("RestrictedApi") inline get() = this as WorkManagerImpl
|
@SuppressLint("RestrictedApi") inline get() = this as WorkManagerImpl
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util.progress
|
|
||||||
|
|
||||||
import androidx.annotation.AnyThread
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import org.koitharu.kotatsu.download.ui.worker.PausingHandle
|
|
||||||
|
|
||||||
class PausingProgressJob<P>(
|
|
||||||
job: Job,
|
|
||||||
progress: StateFlow<P>,
|
|
||||||
private val pausingHandle: PausingHandle,
|
|
||||||
) : ProgressJob<P>(job, progress) {
|
|
||||||
|
|
||||||
@get:AnyThread
|
|
||||||
val isPaused: Boolean
|
|
||||||
get() = pausingHandle.isPaused
|
|
||||||
|
|
||||||
@AnyThread
|
|
||||||
suspend fun awaitResumed() = pausingHandle.awaitResumed()
|
|
||||||
|
|
||||||
@AnyThread
|
|
||||||
fun pause() = pausingHandle.pause()
|
|
||||||
|
|
||||||
@AnyThread
|
|
||||||
fun resume() = pausingHandle.resume()
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.util.progress
|
|
||||||
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
|
|
||||||
open class ProgressJob<P>(
|
|
||||||
private val job: Job,
|
|
||||||
private val progress: StateFlow<P>,
|
|
||||||
) : Job by job {
|
|
||||||
|
|
||||||
val progressValue: P
|
|
||||||
get() = progress.value
|
|
||||||
|
|
||||||
fun progressAsFlow(): Flow<P> = progress
|
|
||||||
}
|
|
||||||
@@ -21,8 +21,7 @@ data class MangaDetails(
|
|||||||
val branches: Set<String?>
|
val branches: Set<String?>
|
||||||
get() = chapters.keys
|
get() = chapters.keys
|
||||||
|
|
||||||
val allChapters: List<MangaChapter>
|
val allChapters: List<MangaChapter> by lazy { mergeChapters() }
|
||||||
get() = manga.chapters.orEmpty()
|
|
||||||
|
|
||||||
val isLocal
|
val isLocal
|
||||||
get() = manga.isLocal
|
get() = manga.isLocal
|
||||||
@@ -40,4 +39,26 @@ data class MangaDetails(
|
|||||||
description = description,
|
description = description,
|
||||||
isLoaded = isLoaded,
|
isLoaded = isLoaded,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun mergeChapters(): List<MangaChapter> {
|
||||||
|
val chapters = manga.chapters
|
||||||
|
val localChapters = local?.manga?.chapters.orEmpty()
|
||||||
|
if (chapters.isNullOrEmpty()) {
|
||||||
|
return localChapters
|
||||||
|
}
|
||||||
|
val localMap = if (localChapters.isNotEmpty()) {
|
||||||
|
localChapters.associateByTo(LinkedHashMap(localChapters.size)) { it.id }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val result = ArrayList<MangaChapter>(chapters.size)
|
||||||
|
for (chapter in chapters) {
|
||||||
|
val local = localMap?.remove(chapter.id)
|
||||||
|
result += local ?: chapter
|
||||||
|
}
|
||||||
|
if (!localMap.isNullOrEmpty()) {
|
||||||
|
result.addAll(localMap.values)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|||||||
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
|
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||||
import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
|
|
||||||
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
|
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
|
||||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
|
||||||
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
|
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.details.ui.adapter
|
|
||||||
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.text.Spannable
|
|
||||||
import android.text.style.ForegroundColorSpan
|
|
||||||
import android.text.style.RelativeSizeSpan
|
|
||||||
import androidx.core.text.buildSpannedString
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding
|
|
||||||
import org.koitharu.kotatsu.details.ui.model.MangaBranch
|
|
||||||
|
|
||||||
fun branchAD(
|
|
||||||
clickListener: OnListItemClickListener<MangaBranch>,
|
|
||||||
) = adapterDelegateViewBinding<MangaBranch, MangaBranch, ItemCheckableNewBinding>(
|
|
||||||
{ inflater, parent -> ItemCheckableNewBinding.inflate(inflater, parent, false) },
|
|
||||||
) {
|
|
||||||
|
|
||||||
val clickAdapter = AdapterDelegateClickListenerAdapter(this, clickListener)
|
|
||||||
itemView.setOnClickListener(clickAdapter)
|
|
||||||
val counterColorSpan = ForegroundColorSpan(context.getThemeColor(android.R.attr.textColorSecondary, Color.LTGRAY))
|
|
||||||
val counterSizeSpan = RelativeSizeSpan(0.86f)
|
|
||||||
|
|
||||||
bind {
|
|
||||||
binding.root.text = buildSpannedString {
|
|
||||||
append(item.name ?: getString(R.string.system_default))
|
|
||||||
append(' ')
|
|
||||||
append(' ')
|
|
||||||
val start = length
|
|
||||||
append(item.count.toString())
|
|
||||||
setSpan(counterColorSpan, start, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
||||||
setSpan(counterSizeSpan, start, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
|
||||||
}
|
|
||||||
binding.root.isChecked = item.isSelected
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
|||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
import org.koitharu.kotatsu.core.util.ext.drawableEnd
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.drawableStart
|
import org.koitharu.kotatsu.core.util.ext.drawableStart
|
||||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ data class DownloadState(
|
|||||||
private const val DATA_PROGRESS = "progress"
|
private const val DATA_PROGRESS = "progress"
|
||||||
private const val DATA_CHAPTERS = "chapter_cnt"
|
private const val DATA_CHAPTERS = "chapter_cnt"
|
||||||
private const val DATA_ETA = "eta"
|
private const val DATA_ETA = "eta"
|
||||||
private const val DATA_TIMESTAMP = "timestamp"
|
const val DATA_TIMESTAMP = "timestamp"
|
||||||
private const val DATA_ERROR = "error"
|
private const val DATA_ERROR = "error"
|
||||||
private const val DATA_INDETERMINATE = "indeterminate"
|
private const val DATA_INDETERMINATE = "indeterminate"
|
||||||
private const val DATA_PAUSED = "paused"
|
private const val DATA_PAUSED = "paused"
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
package org.koitharu.kotatsu.download.ui.list
|
package org.koitharu.kotatsu.download.ui.list
|
||||||
|
|
||||||
import android.transition.TransitionManager
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import androidx.core.view.isGone
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.recyclerview.widget.DividerItemDecoration
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.request.SuccessResult
|
import coil.request.SuccessResult
|
||||||
import coil.util.CoilUtils
|
import coil.util.CoilUtils
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
|
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
|
||||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||||
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
import org.koitharu.kotatsu.core.util.ext.newImageRequest
|
||||||
import org.koitharu.kotatsu.core.util.ext.source
|
import org.koitharu.kotatsu.core.util.ext.source
|
||||||
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||||
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
|
import org.koitharu.kotatsu.databinding.ItemDownloadBinding
|
||||||
|
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
|
||||||
|
import org.koitharu.kotatsu.download.ui.list.chapters.downloadChapterAD
|
||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.parsers.util.format
|
import org.koitharu.kotatsu.parsers.util.format
|
||||||
|
|
||||||
@@ -30,14 +40,16 @@ fun downloadItemAD(
|
|||||||
) {
|
) {
|
||||||
|
|
||||||
val percentPattern = context.resources.getString(R.string.percent_string_pattern)
|
val percentPattern = context.resources.getString(R.string.percent_string_pattern)
|
||||||
// val expandIcon = ContextCompat.getDrawable(context, R.drawable.ic_expand_collapse)
|
var chaptersJob: Job? = null
|
||||||
|
|
||||||
val clickListener = object : View.OnClickListener, View.OnLongClickListener {
|
val clickListener = object : View.OnClickListener, View.OnLongClickListener {
|
||||||
override fun onClick(v: View) {
|
override fun onClick(v: View) {
|
||||||
when (v.id) {
|
when (v.id) {
|
||||||
R.id.button_cancel -> listener.onCancelClick(item)
|
R.id.button_cancel -> listener.onCancelClick(item)
|
||||||
R.id.button_resume -> listener.onResumeClick(item)
|
R.id.button_resume -> listener.onResumeClick(item, skip = false)
|
||||||
|
R.id.button_skip -> listener.onResumeClick(item, skip = true)
|
||||||
R.id.button_pause -> listener.onPauseClick(item)
|
R.id.button_pause -> listener.onPauseClick(item)
|
||||||
|
R.id.imageView_expand -> listener.onExpandClick(item)
|
||||||
else -> listener.onItemClick(item, v)
|
else -> listener.onItemClick(item, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,31 +58,62 @@ fun downloadItemAD(
|
|||||||
return listener.onItemLongClick(item, v)
|
return listener.onItemLongClick(item, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val chaptersAdapter = BaseListAdapter<DownloadChapter>()
|
||||||
|
.addDelegate(ListItemType.CHAPTER, downloadChapterAD())
|
||||||
|
|
||||||
|
binding.recyclerViewChapters.addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL))
|
||||||
|
binding.recyclerViewChapters.adapter = chaptersAdapter
|
||||||
binding.buttonCancel.setOnClickListener(clickListener)
|
binding.buttonCancel.setOnClickListener(clickListener)
|
||||||
binding.buttonPause.setOnClickListener(clickListener)
|
binding.buttonPause.setOnClickListener(clickListener)
|
||||||
binding.buttonResume.setOnClickListener(clickListener)
|
binding.buttonResume.setOnClickListener(clickListener)
|
||||||
|
binding.buttonSkip.setOnClickListener(clickListener)
|
||||||
|
binding.imageViewExpand.setOnClickListener(clickListener)
|
||||||
itemView.setOnClickListener(clickListener)
|
itemView.setOnClickListener(clickListener)
|
||||||
itemView.setOnLongClickListener(clickListener)
|
itemView.setOnLongClickListener(clickListener)
|
||||||
|
|
||||||
bind { payloads ->
|
fun scrollToCurrentChapter() {
|
||||||
if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads && context.isAnimationsEnabled) {
|
val rv = binding.recyclerViewChapters
|
||||||
TransitionManager.beginDelayedTransition(binding.constraintLayout)
|
if (!rv.isVisible) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
binding.textViewTitle.text = item.manga.title
|
val chapters = chaptersAdapter.items
|
||||||
|
if (chapters.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val targetPos = item.chaptersDownloaded.coerceIn(chapters.indices)
|
||||||
|
(rv.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(targetPos, rv.height / 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
bind { payloads ->
|
||||||
|
binding.textViewTitle.text = item.manga?.title ?: getString(R.string.unknown)
|
||||||
if ((CoilUtils.result(binding.imageViewCover) as? SuccessResult)?.memoryCacheKey != item.coverCacheKey) {
|
if ((CoilUtils.result(binding.imageViewCover) as? SuccessResult)?.memoryCacheKey != item.coverCacheKey) {
|
||||||
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.apply {
|
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga?.coverUrl)?.apply {
|
||||||
placeholder(R.drawable.ic_placeholder)
|
placeholder(R.drawable.ic_placeholder)
|
||||||
fallback(R.drawable.ic_placeholder)
|
fallback(R.drawable.ic_placeholder)
|
||||||
error(R.drawable.ic_error_placeholder)
|
error(R.drawable.ic_error_placeholder)
|
||||||
allowRgb565(true)
|
allowRgb565(true)
|
||||||
transformations(TrimTransformation())
|
transformations(TrimTransformation())
|
||||||
memoryCacheKey(item.coverCacheKey)
|
memoryCacheKey(item.coverCacheKey)
|
||||||
source(item.manga.source)
|
source(item.manga?.source)
|
||||||
enqueueWith(coil)
|
enqueueWith(coil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// binding.textViewTitle.isChecked = item.isExpanded
|
if (chaptersJob == null || payloads.isEmpty()) {
|
||||||
// binding.textViewTitle.drawableEnd = if (item.isExpandable) expandIcon else null
|
chaptersJob?.cancel()
|
||||||
|
chaptersJob = lifecycleOwner.lifecycleScope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||||
|
item.chapters.collect { chapters ->
|
||||||
|
binding.imageViewExpand.isGone = chapters.isNullOrEmpty()
|
||||||
|
chaptersAdapter.emit(chapters)
|
||||||
|
scrollToCurrentChapter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads) {
|
||||||
|
binding.recyclerViewChapters.post {
|
||||||
|
scrollToCurrentChapter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding.imageViewExpand.isChecked = item.isExpanded
|
||||||
|
binding.recyclerViewChapters.isVisible = item.isExpanded
|
||||||
when (item.workState) {
|
when (item.workState) {
|
||||||
WorkInfo.State.ENQUEUED,
|
WorkInfo.State.ENQUEUED,
|
||||||
WorkInfo.State.BLOCKED -> {
|
WorkInfo.State.BLOCKED -> {
|
||||||
@@ -82,6 +125,7 @@ fun downloadItemAD(
|
|||||||
binding.textViewDetails.isVisible = false
|
binding.textViewDetails.isVisible = false
|
||||||
binding.buttonCancel.isVisible = true
|
binding.buttonCancel.isVisible = true
|
||||||
binding.buttonResume.isVisible = false
|
binding.buttonResume.isVisible = false
|
||||||
|
binding.buttonSkip.isVisible = false
|
||||||
binding.buttonPause.isVisible = false
|
binding.buttonPause.isVisible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,9 +140,10 @@ fun downloadItemAD(
|
|||||||
binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty())
|
binding.progressBar.setProgressCompat(item.progress, payloads.isNotEmpty())
|
||||||
binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1))
|
binding.textViewPercent.text = percentPattern.format((item.percent * 100f).format(1))
|
||||||
binding.textViewPercent.isVisible = true
|
binding.textViewPercent.isVisible = true
|
||||||
binding.textViewDetails.textAndVisible = item.getEtaString()
|
binding.textViewDetails.textAndVisible = if (item.isPaused) item.error else item.getEtaString()
|
||||||
binding.buttonCancel.isVisible = true
|
binding.buttonCancel.isVisible = true
|
||||||
binding.buttonResume.isVisible = item.isPaused
|
binding.buttonResume.isVisible = item.isPaused
|
||||||
|
binding.buttonSkip.isVisible = item.isPaused && item.error != null
|
||||||
binding.buttonPause.isVisible = item.canPause
|
binding.buttonPause.isVisible = item.canPause
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +165,7 @@ fun downloadItemAD(
|
|||||||
}
|
}
|
||||||
binding.buttonCancel.isVisible = false
|
binding.buttonCancel.isVisible = false
|
||||||
binding.buttonResume.isVisible = false
|
binding.buttonResume.isVisible = false
|
||||||
|
binding.buttonSkip.isVisible = false
|
||||||
binding.buttonPause.isVisible = false
|
binding.buttonPause.isVisible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +178,7 @@ fun downloadItemAD(
|
|||||||
binding.textViewDetails.textAndVisible = item.error
|
binding.textViewDetails.textAndVisible = item.error
|
||||||
binding.buttonCancel.isVisible = false
|
binding.buttonCancel.isVisible = false
|
||||||
binding.buttonResume.isVisible = false
|
binding.buttonResume.isVisible = false
|
||||||
|
binding.buttonSkip.isVisible = false
|
||||||
binding.buttonPause.isVisible = false
|
binding.buttonPause.isVisible = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +191,7 @@ fun downloadItemAD(
|
|||||||
binding.textViewDetails.isVisible = false
|
binding.textViewDetails.isVisible = false
|
||||||
binding.buttonCancel.isVisible = false
|
binding.buttonCancel.isVisible = false
|
||||||
binding.buttonResume.isVisible = false
|
binding.buttonResume.isVisible = false
|
||||||
|
binding.buttonSkip.isVisible = false
|
||||||
binding.buttonPause.isVisible = false
|
binding.buttonPause.isVisible = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,7 @@ interface DownloadItemListener : OnListItemClickListener<DownloadItemModel> {
|
|||||||
|
|
||||||
fun onPauseClick(item: DownloadItemModel)
|
fun onPauseClick(item: DownloadItemModel)
|
||||||
|
|
||||||
fun onResumeClick(item: DownloadItemModel)
|
fun onResumeClick(item: DownloadItemModel, skip: Boolean)
|
||||||
|
|
||||||
|
fun onExpandClick(item: DownloadItemModel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package org.koitharu.kotatsu.download.ui.list
|
|||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
import coil.memory.MemoryCache
|
import coil.memory.MemoryCache
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
|
||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
@@ -14,7 +16,7 @@ data class DownloadItemModel(
|
|||||||
val workState: WorkInfo.State,
|
val workState: WorkInfo.State,
|
||||||
val isIndeterminate: Boolean,
|
val isIndeterminate: Boolean,
|
||||||
val isPaused: Boolean,
|
val isPaused: Boolean,
|
||||||
val manga: Manga,
|
val manga: Manga?,
|
||||||
val error: String?,
|
val error: String?,
|
||||||
val max: Int,
|
val max: Int,
|
||||||
val progress: Int,
|
val progress: Int,
|
||||||
@@ -22,9 +24,10 @@ data class DownloadItemModel(
|
|||||||
val timestamp: Date,
|
val timestamp: Date,
|
||||||
val chaptersDownloaded: Int,
|
val chaptersDownloaded: Int,
|
||||||
val isExpanded: Boolean,
|
val isExpanded: Boolean,
|
||||||
|
val chapters: StateFlow<List<DownloadChapter>?>,
|
||||||
) : ListModel, Comparable<DownloadItemModel> {
|
) : ListModel, Comparable<DownloadItemModel> {
|
||||||
|
|
||||||
val coverCacheKey = MemoryCache.Key(manga.coverUrl, mapOf("dl" to "1"))
|
val coverCacheKey = MemoryCache.Key(manga?.coverUrl.orEmpty(), mapOf("dl" to "1"))
|
||||||
|
|
||||||
val percent: Float
|
val percent: Float
|
||||||
get() = if (max > 0) progress / max.toFloat() else 0f
|
get() = if (max > 0) progress / max.toFloat() else 0f
|
||||||
@@ -38,9 +41,6 @@ data class DownloadItemModel(
|
|||||||
val canResume: Boolean
|
val canResume: Boolean
|
||||||
get() = workState == WorkInfo.State.RUNNING && isPaused
|
get() = workState == WorkInfo.State.RUNNING && isPaused
|
||||||
|
|
||||||
val isExpandable: Boolean
|
|
||||||
get() = false // TODO
|
|
||||||
|
|
||||||
fun getEtaString(): CharSequence? = if (hasEta) {
|
fun getEtaString(): CharSequence? = if (hasEta) {
|
||||||
DateUtils.getRelativeTimeSpanString(
|
DateUtils.getRelativeTimeSpanString(
|
||||||
eta,
|
eta,
|
||||||
|
|||||||
@@ -84,17 +84,19 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
|
|||||||
if (selectionController.onItemClick(item.id.mostSignificantBits)) {
|
if (selectionController.onItemClick(item.id.mostSignificantBits)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (item.isExpandable) {
|
startActivity(DetailsActivity.newIntent(view.context, item.manga ?: return))
|
||||||
viewModel.expandCollapse(item)
|
|
||||||
} else {
|
|
||||||
startActivity(DetailsActivity.newIntent(view.context, item.manga))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean {
|
override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean {
|
||||||
return selectionController.onItemLongClick(item.id.mostSignificantBits)
|
return selectionController.onItemLongClick(item.id.mostSignificantBits)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onExpandClick(item: DownloadItemModel) {
|
||||||
|
if (!selectionController.onItemClick(item.id.mostSignificantBits)) {
|
||||||
|
viewModel.expandCollapse(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCancelClick(item: DownloadItemModel) {
|
override fun onCancelClick(item: DownloadItemModel) {
|
||||||
viewModel.cancel(item.id)
|
viewModel.cancel(item.id)
|
||||||
}
|
}
|
||||||
@@ -103,8 +105,8 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
|
|||||||
sendBroadcast(PausingReceiver.getPauseIntent(this, item.id))
|
sendBroadcast(PausingReceiver.getPauseIntent(this, item.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResumeClick(item: DownloadItemModel) {
|
override fun onResumeClick(item: DownloadItemModel, skip: Boolean) {
|
||||||
sendBroadcast(PausingReceiver.getResumeIntent(this, item.id))
|
sendBroadcast(PausingReceiver.getResumeIntent(this, item.id, skip))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
|
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
package org.koitharu.kotatsu.download.ui.list
|
package org.koitharu.kotatsu.download.ui.list
|
||||||
|
|
||||||
|
import androidx.collection.ArrayMap
|
||||||
import androidx.collection.LongSparseArray
|
import androidx.collection.LongSparseArray
|
||||||
import androidx.collection.getOrElse
|
import androidx.collection.getOrElse
|
||||||
import androidx.collection.set
|
import androidx.collection.set
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.work.Data
|
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
@@ -27,12 +30,17 @@ import org.koitharu.kotatsu.core.ui.util.ReversibleAction
|
|||||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||||
import org.koitharu.kotatsu.core.util.ext.call
|
import org.koitharu.kotatsu.core.util.ext.call
|
||||||
import org.koitharu.kotatsu.core.util.ext.daysDiff
|
import org.koitharu.kotatsu.core.util.ext.daysDiff
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isEmpty
|
||||||
import org.koitharu.kotatsu.download.domain.DownloadState
|
import org.koitharu.kotatsu.download.domain.DownloadState
|
||||||
|
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
|
||||||
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
|
||||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||||
|
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||||
|
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||||
|
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToSet
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
@@ -47,11 +55,15 @@ class DownloadsViewModel @Inject constructor(
|
|||||||
private val workScheduler: DownloadWorker.Scheduler,
|
private val workScheduler: DownloadWorker.Scheduler,
|
||||||
private val mangaDataRepository: MangaDataRepository,
|
private val mangaDataRepository: MangaDataRepository,
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
|
||||||
|
private val localMangaRepository: LocalMangaRepository,
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
private val mangaCache = LongSparseArray<Manga>()
|
private val mangaCache = LongSparseArray<Manga>()
|
||||||
private val cacheMutex = Mutex()
|
private val cacheMutex = Mutex()
|
||||||
private val expanded = MutableStateFlow(emptySet<UUID>())
|
private val expanded = MutableStateFlow(emptySet<UUID>())
|
||||||
|
private val chaptersCache = ArrayMap<UUID, StateFlow<List<DownloadChapter>?>>()
|
||||||
|
|
||||||
private val works = combine(
|
private val works = combine(
|
||||||
workScheduler.observeWorks(),
|
workScheduler.observeWorks(),
|
||||||
expanded,
|
expanded,
|
||||||
@@ -131,7 +143,7 @@ class DownloadsViewModel @Inject constructor(
|
|||||||
var isResumed = false
|
var isResumed = false
|
||||||
for (work in snapshot) {
|
for (work in snapshot) {
|
||||||
if (work.workState == WorkInfo.State.RUNNING && work.isPaused) {
|
if (work.workState == WorkInfo.State.RUNNING && work.isPaused) {
|
||||||
workScheduler.resume(work.id)
|
workScheduler.resume(work.id, skipError = false)
|
||||||
isResumed = true
|
isResumed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,7 +156,7 @@ class DownloadsViewModel @Inject constructor(
|
|||||||
val snapshot = works.value ?: return
|
val snapshot = works.value ?: return
|
||||||
for (work in snapshot) {
|
for (work in snapshot) {
|
||||||
if (work.id.mostSignificantBits in ids) {
|
if (work.id.mostSignificantBits in ids) {
|
||||||
workScheduler.resume(work.id)
|
workScheduler.resume(work.id, skipError = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onActionDone.call(ReversibleAction(R.string.downloads_resumed, null))
|
onActionDone.call(ReversibleAction(R.string.downloads_resumed, null))
|
||||||
@@ -234,10 +246,18 @@ class DownloadsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun WorkInfo.toUiModel(isExpanded: Boolean): DownloadItemModel? {
|
private suspend fun WorkInfo.toUiModel(isExpanded: Boolean): DownloadItemModel? {
|
||||||
val workData = if (outputData == Data.EMPTY) progress else outputData
|
val workData = outputData.takeUnless { it.isEmpty }
|
||||||
|
?: progress.takeUnless { it.isEmpty }
|
||||||
|
?: workScheduler.getInputData(id)
|
||||||
|
?: return null
|
||||||
val mangaId = DownloadState.getMangaId(workData)
|
val mangaId = DownloadState.getMangaId(workData)
|
||||||
if (mangaId == 0L) return null
|
if (mangaId == 0L) return null
|
||||||
val manga = getManga(mangaId) ?: return null
|
val manga = getManga(mangaId) ?: return null
|
||||||
|
val chapters = synchronized(chaptersCache) {
|
||||||
|
chaptersCache.getOrPut(id) {
|
||||||
|
observeChapters(manga, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
return DownloadItemModel(
|
return DownloadItemModel(
|
||||||
id = id,
|
id = id,
|
||||||
workState = state,
|
workState = state,
|
||||||
@@ -251,6 +271,7 @@ class DownloadsViewModel @Inject constructor(
|
|||||||
timestamp = DownloadState.getTimestamp(workData),
|
timestamp = DownloadState.getTimestamp(workData),
|
||||||
chaptersDownloaded = DownloadState.getDownloadedChapters(workData),
|
chaptersDownloaded = DownloadState.getDownloadedChapters(workData),
|
||||||
isExpanded = isExpanded,
|
isExpanded = isExpanded,
|
||||||
|
chapters = chapters,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,16 +303,42 @@ class DownloadsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
return cacheMutex.withLock {
|
return cacheMutex.withLock {
|
||||||
mangaCache.getOrElse(mangaId) {
|
mangaCache.getOrElse(mangaId) {
|
||||||
mangaDataRepository.findMangaById(mangaId)?.let {
|
mangaDataRepository.findMangaById(mangaId)?.also {
|
||||||
tryLoad(it) ?: it
|
|
||||||
}?.also {
|
|
||||||
mangaCache[mangaId] = it
|
mangaCache[mangaId] = it
|
||||||
} ?: return null
|
} ?: return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun observeChapters(manga: Manga, workId: UUID): StateFlow<List<DownloadChapter>?> = flow {
|
||||||
|
val chapterIds = workScheduler.getInputChaptersIds(workId)?.toSet()
|
||||||
|
val chapters = (tryLoad(manga) ?: manga).chapters ?: return@flow
|
||||||
|
|
||||||
|
suspend fun mapChapters(): List<DownloadChapter> {
|
||||||
|
val size = chapterIds?.size ?: chapters.size
|
||||||
|
val localChapters =
|
||||||
|
localMangaRepository.findSavedManga(manga)?.manga?.chapters?.mapToSet { it.id }.orEmpty()
|
||||||
|
return chapters.mapNotNullTo(ArrayList(size)) {
|
||||||
|
if (chapterIds == null || it.id in chapterIds) {
|
||||||
|
DownloadChapter(
|
||||||
|
number = it.number,
|
||||||
|
name = it.name,
|
||||||
|
isDownloaded = it.id in localChapters,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit(mapChapters())
|
||||||
|
localStorageChanges.collect {
|
||||||
|
if (it?.manga?.id == manga.id) {
|
||||||
|
emit(mapChapters())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||||
|
|
||||||
private suspend fun tryLoad(manga: Manga) = runCatchingCancellable {
|
private suspend fun tryLoad(manga: Manga) = runCatchingCancellable {
|
||||||
(mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).peekDetails(manga)
|
(mangaRepositoryFactory.create(manga.source) as RemoteMangaRepository).getDetails(manga)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.download.ui.list.chapters
|
package org.koitharu.kotatsu.download.ui.list.chapters
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
data class DownloadChapter(
|
data class DownloadChapter(
|
||||||
@@ -11,4 +12,12 @@ data class DownloadChapter(
|
|||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
return other is DownloadChapter && other.name == name
|
return other is DownloadChapter && other.name == name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun getChangePayload(previousState: ListModel): Any? {
|
||||||
|
return if (previousState is DownloadChapter && previousState.name == name && previousState.number == number) {
|
||||||
|
ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED
|
||||||
|
} else {
|
||||||
|
super.getChangePayload(previousState)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,15 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
|||||||
NotificationCompat.Action(
|
NotificationCompat.Action(
|
||||||
R.drawable.ic_action_resume,
|
R.drawable.ic_action_resume,
|
||||||
context.getString(R.string.resume),
|
context.getString(R.string.resume),
|
||||||
PausingReceiver.createResumePendingIntent(context, uuid),
|
PausingReceiver.createResumePendingIntent(context, uuid, skipError = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val actionSkip by lazy {
|
||||||
|
NotificationCompat.Action(
|
||||||
|
R.drawable.ic_action_skip,
|
||||||
|
context.getString(R.string.skip),
|
||||||
|
PausingReceiver.createResumePendingIntent(context, uuid, skipError = true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +171,9 @@ class DownloadNotificationFactory @AssistedInject constructor(
|
|||||||
builder.setSmallIcon(R.drawable.ic_stat_paused)
|
builder.setSmallIcon(R.drawable.ic_stat_paused)
|
||||||
builder.addAction(actionCancel)
|
builder.addAction(actionCancel)
|
||||||
builder.addAction(actionResume)
|
builder.addAction(actionResume)
|
||||||
|
if (state.error != null) {
|
||||||
|
builder.addAction(actionSkip)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
state.error != null -> { // error, final state
|
state.error != null -> { // error, final state
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package org.koitharu.kotatsu.download.ui.worker
|
||||||
|
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
class DownloadSlowdownDispatcher(
|
||||||
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
private val defaultDelay: Long,
|
||||||
|
) {
|
||||||
|
private val timeMap = HashMap<MangaSource, Long>()
|
||||||
|
|
||||||
|
suspend fun delay(source: MangaSource) {
|
||||||
|
val repo = mangaRepositoryFactory.create(source) as? RemoteMangaRepository ?: return
|
||||||
|
if (!repo.isSlowdownEnabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val lastRequest = synchronized(timeMap) {
|
||||||
|
val res = timeMap[source] ?: 0L
|
||||||
|
timeMap[source] = System.currentTimeMillis()
|
||||||
|
res
|
||||||
|
}
|
||||||
|
if (lastRequest != 0L) {
|
||||||
|
delay(lastRequest + defaultDelay - System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.koitharu.kotatsu.download.ui.worker
|
package org.koitharu.kotatsu.download.ui.worker
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
@@ -7,7 +8,6 @@ import android.os.Build
|
|||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.hilt.work.HiltWorker
|
import androidx.hilt.work.HiltWorker
|
||||||
import androidx.lifecycle.asFlow
|
|
||||||
import androidx.work.BackoffPolicy
|
import androidx.work.BackoffPolicy
|
||||||
import androidx.work.Constraints
|
import androidx.work.Constraints
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
@@ -29,6 +29,10 @@ import kotlinx.coroutines.NonCancellable
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
@@ -52,6 +56,8 @@ import org.koitharu.kotatsu.core.util.ext.deleteAwait
|
|||||||
import org.koitharu.kotatsu.core.util.ext.deleteWork
|
import org.koitharu.kotatsu.core.util.ext.deleteWork
|
||||||
import org.koitharu.kotatsu.core.util.ext.deleteWorks
|
import org.koitharu.kotatsu.core.util.ext.deleteWorks
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getWorkInputData
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getWorkSpec
|
||||||
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
import org.koitharu.kotatsu.core.util.ext.ifNullOrEmpty
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
|
||||||
@@ -60,6 +66,7 @@ import org.koitharu.kotatsu.download.domain.DownloadState
|
|||||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||||
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
import org.koitharu.kotatsu.local.data.LocalStorageChanges
|
||||||
import org.koitharu.kotatsu.local.data.PagesCache
|
import org.koitharu.kotatsu.local.data.PagesCache
|
||||||
|
import org.koitharu.kotatsu.local.data.TempFileFilter
|
||||||
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
import org.koitharu.kotatsu.local.data.input.LocalMangaInput
|
||||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
||||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||||
@@ -72,6 +79,7 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltWorker
|
@HiltWorker
|
||||||
@@ -82,7 +90,6 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
private val cache: PagesCache,
|
private val cache: PagesCache,
|
||||||
private val localMangaRepository: LocalMangaRepository,
|
private val localMangaRepository: LocalMangaRepository,
|
||||||
private val mangaDataRepository: MangaDataRepository,
|
private val mangaDataRepository: MangaDataRepository,
|
||||||
private val settings: AppSettings,
|
|
||||||
private val mangaRepositoryFactory: MangaRepository.Factory,
|
private val mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
|
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
|
||||||
notificationFactoryFactory: DownloadNotificationFactory.Factory,
|
notificationFactoryFactory: DownloadNotificationFactory.Factory,
|
||||||
@@ -90,16 +97,15 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
|
|
||||||
private val notificationFactory = notificationFactoryFactory.create(params.id)
|
private val notificationFactory = notificationFactoryFactory.create(params.id)
|
||||||
private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
private val slowdownDispatcher = DownloadSlowdownDispatcher(mangaRepositoryFactory, SLOWDOWN_DELAY)
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var lastPublishedState: DownloadState? = null
|
private var lastPublishedState: DownloadState? = null
|
||||||
private val currentState: DownloadState
|
private val currentState: DownloadState
|
||||||
get() = checkNotNull(lastPublishedState)
|
get() = checkNotNull(lastPublishedState)
|
||||||
|
|
||||||
private val pausingHandle = PausingHandle()
|
|
||||||
private val timeLeftEstimator = TimeLeftEstimator()
|
private val timeLeftEstimator = TimeLeftEstimator()
|
||||||
private val notificationThrottler = Throttler(400)
|
private val notificationThrottler = Throttler(400)
|
||||||
private val pausingReceiver = PausingReceiver(params.id, pausingHandle)
|
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
setForeground(getForegroundInfo())
|
setForeground(getForegroundInfo())
|
||||||
@@ -110,14 +116,18 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
|
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
|
||||||
val downloadedIds = getDoneChapters(manga)
|
val downloadedIds = getDoneChapters(manga)
|
||||||
return try {
|
return try {
|
||||||
downloadMangaImpl(manga, chaptersIds, downloadedIds)
|
withContext(PausingHandle()) {
|
||||||
|
downloadMangaImpl(manga, chaptersIds, downloadedIds)
|
||||||
|
}
|
||||||
Result.success(currentState.toWorkData())
|
Result.success(currentState.toWorkData())
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
withContext(NonCancellable) {
|
withContext(NonCancellable) {
|
||||||
val notification = notificationFactory.create(currentState.copy(isStopped = true))
|
val notification = notificationFactory.create(currentState.copy(isStopped = true))
|
||||||
notificationManager.notify(id.hashCode(), notification)
|
notificationManager.notify(id.hashCode(), notification)
|
||||||
}
|
}
|
||||||
throw e
|
Result.failure(
|
||||||
|
currentState.copy(eta = -1L).toWorkData(),
|
||||||
|
)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
e.printStackTraceDebug()
|
e.printStackTraceDebug()
|
||||||
Result.retry()
|
Result.retry()
|
||||||
@@ -154,6 +164,7 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
) {
|
) {
|
||||||
var manga = subject
|
var manga = subject
|
||||||
val chaptersToSkip = excludedIds.toMutableSet()
|
val chaptersToSkip = excludedIds.toMutableSet()
|
||||||
|
val pausingReceiver = PausingReceiver(id, PausingHandle.current())
|
||||||
withMangaLock(manga) {
|
withMangaLock(manga) {
|
||||||
ContextCompat.registerReceiver(
|
ContextCompat.registerReceiver(
|
||||||
applicationContext,
|
applicationContext,
|
||||||
@@ -163,7 +174,6 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
)
|
)
|
||||||
val destination = localMangaRepository.getOutputDir(manga)
|
val destination = localMangaRepository.getOutputDir(manga)
|
||||||
checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) }
|
checkNotNull(destination) { applicationContext.getString(R.string.cannot_find_available_storage) }
|
||||||
val tempFileName = "${manga.id}_$id.tmp"
|
|
||||||
var output: LocalMangaOutput? = null
|
var output: LocalMangaOutput? = null
|
||||||
try {
|
try {
|
||||||
if (manga.source == MangaSource.LOCAL) {
|
if (manga.source == MangaSource.LOCAL) {
|
||||||
@@ -175,45 +185,57 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
output = LocalMangaOutput.getOrCreate(destination, mangaDetails)
|
output = LocalMangaOutput.getOrCreate(destination, mangaDetails)
|
||||||
val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl }
|
val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl }
|
||||||
if (coverUrl.isNotEmpty()) {
|
if (coverUrl.isNotEmpty()) {
|
||||||
downloadFile(coverUrl, destination, tempFileName, repo.source).let { file ->
|
downloadFile(coverUrl, destination, repo.source).let { file ->
|
||||||
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
|
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
|
||||||
|
file.deleteAwait()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val chapters = getChapters(mangaDetails, includedIds)
|
val chapters = getChapters(mangaDetails, includedIds)
|
||||||
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
for ((chapterIndex, chapter) in chapters.withIndex()) {
|
||||||
|
checkIsPaused()
|
||||||
if (chaptersToSkip.remove(chapter.id)) {
|
if (chaptersToSkip.remove(chapter.id)) {
|
||||||
publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1))
|
publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
val pages = runFailsafe(pausingHandle) {
|
val pages = runFailsafe {
|
||||||
repo.getPages(chapter)
|
repo.getPages(chapter)
|
||||||
}
|
} ?: continue
|
||||||
for ((pageIndex, page) in pages.withIndex()) {
|
val pageCounter = AtomicInteger(0)
|
||||||
runFailsafe(pausingHandle) {
|
channelFlow {
|
||||||
val url = repo.getPageUrl(page)
|
val semaphore = Semaphore(MAX_PAGES_PARALLELISM)
|
||||||
val file = cache.get(url)
|
for ((pageIndex, page) in pages.withIndex()) {
|
||||||
?: downloadFile(url, destination, tempFileName, repo.source)
|
checkIsPaused()
|
||||||
output.addPage(
|
launch {
|
||||||
chapter = chapter,
|
semaphore.withPermit {
|
||||||
file = file,
|
runFailsafe {
|
||||||
pageNumber = pageIndex,
|
val url = repo.getPageUrl(page)
|
||||||
ext = MimeTypeMap.getFileExtensionFromUrl(url),
|
val file = cache.get(url)
|
||||||
)
|
?: downloadFile(url, destination, repo.source)
|
||||||
|
output.addPage(
|
||||||
|
chapter = chapter,
|
||||||
|
file = file,
|
||||||
|
pageNumber = pageIndex,
|
||||||
|
ext = MimeTypeMap.getFileExtensionFromUrl(url),
|
||||||
|
)
|
||||||
|
if (file.extension == "tmp") {
|
||||||
|
file.deleteAwait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
send(pageIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}.collect {
|
||||||
publishState(
|
publishState(
|
||||||
currentState.copy(
|
currentState.copy(
|
||||||
totalChapters = chapters.size,
|
totalChapters = chapters.size,
|
||||||
currentChapter = chapterIndex,
|
currentChapter = chapterIndex,
|
||||||
totalPages = pages.size,
|
totalPages = pages.size,
|
||||||
currentPage = pageIndex,
|
currentPage = pageCounter.incrementAndGet(),
|
||||||
isIndeterminate = false,
|
isIndeterminate = false,
|
||||||
eta = timeLeftEstimator.getEta(),
|
eta = timeLeftEstimator.getEta(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (settings.isDownloadsSlowdownEnabled) {
|
|
||||||
delay(SLOWDOWN_DELAY)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (output.flushChapter(chapter)) {
|
if (output.flushChapter(chapter)) {
|
||||||
runCatchingCancellable {
|
runCatchingCancellable {
|
||||||
@@ -238,21 +260,18 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
applicationContext.unregisterReceiver(pausingReceiver)
|
applicationContext.unregisterReceiver(pausingReceiver)
|
||||||
output?.closeQuietly()
|
output?.closeQuietly()
|
||||||
output?.cleanup()
|
output?.cleanup()
|
||||||
File(destination, tempFileName).deleteAwait()
|
destination.listFiles(TempFileFilter())?.forEach {
|
||||||
|
it.deleteAwait()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun <R> runFailsafe(
|
private suspend fun <R> runFailsafe(
|
||||||
pausingHandle: PausingHandle,
|
|
||||||
block: suspend () -> R,
|
block: suspend () -> R,
|
||||||
): R {
|
): R? {
|
||||||
if (pausingHandle.isPaused) {
|
checkIsPaused()
|
||||||
publishState(currentState.copy(isPaused = true, eta = -1L))
|
|
||||||
pausingHandle.awaitResumed()
|
|
||||||
publishState(currentState.copy(isPaused = false))
|
|
||||||
}
|
|
||||||
var countDown = MAX_FAILSAFE_ATTEMPTS
|
var countDown = MAX_FAILSAFE_ATTEMPTS
|
||||||
failsafe@ while (true) {
|
failsafe@ while (true) {
|
||||||
try {
|
try {
|
||||||
@@ -267,9 +286,16 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
countDown = MAX_FAILSAFE_ATTEMPTS
|
countDown = MAX_FAILSAFE_ATTEMPTS
|
||||||
|
val pausingHandle = PausingHandle.current()
|
||||||
pausingHandle.pause()
|
pausingHandle.pause()
|
||||||
pausingHandle.awaitResumed()
|
try {
|
||||||
publishState(currentState.copy(isPaused = false, error = null))
|
pausingHandle.awaitResumed()
|
||||||
|
if (pausingHandle.skipCurrentError()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
publishState(currentState.copy(isPaused = false, error = null))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
countDown--
|
countDown--
|
||||||
val retryDelay = if (e is TooManyRequestExceptions) {
|
val retryDelay = if (e is TooManyRequestExceptions) {
|
||||||
@@ -283,10 +309,21 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun checkIsPaused() {
|
||||||
|
val pausingHandle = PausingHandle.current()
|
||||||
|
if (pausingHandle.isPaused) {
|
||||||
|
publishState(currentState.copy(isPaused = true, eta = -1L))
|
||||||
|
try {
|
||||||
|
pausingHandle.awaitResumed()
|
||||||
|
} finally {
|
||||||
|
publishState(currentState.copy(isPaused = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun downloadFile(
|
private suspend fun downloadFile(
|
||||||
url: String,
|
url: String,
|
||||||
destination: File,
|
destination: File,
|
||||||
tempFileName: String,
|
|
||||||
source: MangaSource,
|
source: MangaSource,
|
||||||
): File {
|
): File {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
@@ -296,13 +333,19 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
|
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
|
||||||
.get()
|
.get()
|
||||||
.build()
|
.build()
|
||||||
|
slowdownDispatcher.delay(source)
|
||||||
val call = okHttp.newCall(request)
|
val call = okHttp.newCall(request)
|
||||||
val file = File(destination, tempFileName)
|
val file = File(destination, UUID.randomUUID().toString() + ".tmp")
|
||||||
val response = call.clone().await()
|
try {
|
||||||
checkNotNull(response.body).use { body ->
|
val response = call.clone().await()
|
||||||
file.sink(append = false).buffer().use {
|
checkNotNull(response.body).use { body ->
|
||||||
it.writeAllCancellable(body.source())
|
file.sink(append = false).buffer().use {
|
||||||
|
it.writeAllCancellable(body.source())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
file.delete()
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
return file
|
return file
|
||||||
}
|
}
|
||||||
@@ -385,8 +428,20 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun observeWorks(): Flow<List<WorkInfo>> = workManager
|
fun observeWorks(): Flow<List<WorkInfo>> = workManager
|
||||||
.getWorkInfosByTagLiveData(TAG)
|
.getWorkInfosByTagFlow(TAG)
|
||||||
.asFlow()
|
|
||||||
|
@SuppressLint("RestrictedApi")
|
||||||
|
suspend fun getInputData(id: UUID): Data? {
|
||||||
|
val spec = workManager.getWorkSpec(id) ?: return null
|
||||||
|
return Data.Builder()
|
||||||
|
.putAll(spec.input)
|
||||||
|
.putLong(DownloadState.DATA_TIMESTAMP, spec.scheduleRequestedAt)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getInputChaptersIds(workId: UUID): LongArray? {
|
||||||
|
return workManager.getWorkInputData(workId)?.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun cancel(id: UUID) {
|
suspend fun cancel(id: UUID) {
|
||||||
workManager.cancelWorkById(id).await()
|
workManager.cancelWorkById(id).await()
|
||||||
@@ -401,8 +456,8 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
context.sendBroadcast(intent)
|
context.sendBroadcast(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resume(id: UUID) {
|
fun resume(id: UUID, skipError: Boolean) {
|
||||||
val intent = PausingReceiver.getResumeIntent(context, id)
|
val intent = PausingReceiver.getResumeIntent(context, id, skipError)
|
||||||
context.sendBroadcast(intent)
|
context.sendBroadcast(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,8 +518,9 @@ class DownloadWorker @AssistedInject constructor(
|
|||||||
private companion object {
|
private companion object {
|
||||||
|
|
||||||
const val MAX_FAILSAFE_ATTEMPTS = 2
|
const val MAX_FAILSAFE_ATTEMPTS = 2
|
||||||
|
const val MAX_PAGES_PARALLELISM = 4
|
||||||
const val DOWNLOAD_ERROR_DELAY = 500L
|
const val DOWNLOAD_ERROR_DELAY = 500L
|
||||||
const val SLOWDOWN_DELAY = 100L
|
const val SLOWDOWN_DELAY = 200L
|
||||||
const val MANGA_ID = "manga_id"
|
const val MANGA_ID = "manga_id"
|
||||||
const val CHAPTERS_IDS = "chapters"
|
const val CHAPTERS_IDS = "chapters"
|
||||||
const val TAG = "download"
|
const val TAG = "download"
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package org.koitharu.kotatsu.download.ui.worker
|
package org.koitharu.kotatsu.download.ui.worker
|
||||||
|
|
||||||
import androidx.annotation.AnyThread
|
import androidx.annotation.AnyThread
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.filter
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
class PausingHandle {
|
class PausingHandle : AbstractCoroutineContextElement(PausingHandle) {
|
||||||
|
|
||||||
private val paused = MutableStateFlow(false)
|
private val paused = MutableStateFlow(false)
|
||||||
|
private val isSkipError = MutableStateFlow(false)
|
||||||
|
|
||||||
@get:AnyThread
|
@get:AnyThread
|
||||||
val isPaused: Boolean
|
val isPaused: Boolean
|
||||||
@@ -15,7 +18,7 @@ class PausingHandle {
|
|||||||
|
|
||||||
@AnyThread
|
@AnyThread
|
||||||
suspend fun awaitResumed() {
|
suspend fun awaitResumed() {
|
||||||
paused.filter { !it }.first()
|
paused.first { !it }
|
||||||
}
|
}
|
||||||
|
|
||||||
@AnyThread
|
@AnyThread
|
||||||
@@ -24,7 +27,23 @@ class PausingHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@AnyThread
|
@AnyThread
|
||||||
fun resume() {
|
fun resume(skipError: Boolean) {
|
||||||
|
isSkipError.value = skipError
|
||||||
paused.value = false
|
paused.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun yield() {
|
||||||
|
if (paused.value) {
|
||||||
|
paused.first { !it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun skipCurrentError(): Boolean = isSkipError.compareAndSet(expect = true, update = false)
|
||||||
|
|
||||||
|
companion object : CoroutineContext.Key<PausingHandle> {
|
||||||
|
|
||||||
|
suspend fun current() = checkNotNull(currentCoroutineContext()[this]) {
|
||||||
|
"PausingHandle not found in current context"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ class PausingReceiver(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
ACTION_RESUME -> pausingHandle.resume()
|
ACTION_RESUME -> pausingHandle.resume(skipError = false)
|
||||||
|
ACTION_SKIP -> pausingHandle.resume(skipError = true)
|
||||||
ACTION_PAUSE -> pausingHandle.pause()
|
ACTION_PAUSE -> pausingHandle.pause()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,12 +31,14 @@ class PausingReceiver(
|
|||||||
|
|
||||||
private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE"
|
private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE"
|
||||||
private const val ACTION_RESUME = "org.koitharu.kotatsu.download.RESUME"
|
private const val ACTION_RESUME = "org.koitharu.kotatsu.download.RESUME"
|
||||||
|
private const val ACTION_SKIP = "org.koitharu.kotatsu.download.SKIP"
|
||||||
private const val EXTRA_UUID = "uuid"
|
private const val EXTRA_UUID = "uuid"
|
||||||
private const val SCHEME = "workuid"
|
private const val SCHEME = "workuid"
|
||||||
|
|
||||||
fun createIntentFilter(id: UUID) = IntentFilter().apply {
|
fun createIntentFilter(id: UUID) = IntentFilter().apply {
|
||||||
addAction(ACTION_PAUSE)
|
addAction(ACTION_PAUSE)
|
||||||
addAction(ACTION_RESUME)
|
addAction(ACTION_RESUME)
|
||||||
|
addAction(ACTION_SKIP)
|
||||||
addDataScheme(SCHEME)
|
addDataScheme(SCHEME)
|
||||||
addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB)
|
addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB)
|
||||||
}
|
}
|
||||||
@@ -45,8 +48,9 @@ class PausingReceiver(
|
|||||||
.setPackage(context.packageName)
|
.setPackage(context.packageName)
|
||||||
.putExtra(EXTRA_UUID, id.toString())
|
.putExtra(EXTRA_UUID, id.toString())
|
||||||
|
|
||||||
fun getResumeIntent(context: Context, id: UUID) = Intent(ACTION_RESUME)
|
fun getResumeIntent(context: Context, id: UUID, skipError: Boolean) = Intent(
|
||||||
.setData(Uri.parse("$SCHEME://$id"))
|
if (skipError) ACTION_SKIP else ACTION_RESUME,
|
||||||
|
).setData(Uri.parse("$SCHEME://$id"))
|
||||||
.setPackage(context.packageName)
|
.setPackage(context.packageName)
|
||||||
.putExtra(EXTRA_UUID, id.toString())
|
.putExtra(EXTRA_UUID, id.toString())
|
||||||
|
|
||||||
@@ -58,12 +62,13 @@ class PausingReceiver(
|
|||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun createResumePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast(
|
fun createResumePendingIntent(context: Context, id: UUID, skipError: Boolean) =
|
||||||
context,
|
PendingIntentCompat.getBroadcast(
|
||||||
0,
|
context,
|
||||||
getResumeIntent(context, id),
|
0,
|
||||||
0,
|
getResumeIntent(context, id, skipError),
|
||||||
false,
|
0,
|
||||||
)
|
false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,11 @@ class FavouriteCategoriesActivity :
|
|||||||
}
|
}
|
||||||
val fromPos = viewHolder.bindingAdapterPosition
|
val fromPos = viewHolder.bindingAdapterPosition
|
||||||
val toPos = target.bindingAdapterPosition
|
val toPos = target.bindingAdapterPosition
|
||||||
return fromPos != toPos && fromPos != RecyclerView.NO_POSITION && toPos != RecyclerView.NO_POSITION
|
if (fromPos == toPos || fromPos == RecyclerView.NO_POSITION || toPos == RecyclerView.NO_POSITION) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
adapter.reorderItems(fromPos, toPos)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun canDropOver(
|
override fun canDropOver(
|
||||||
@@ -151,25 +155,16 @@ class FavouriteCategoriesActivity :
|
|||||||
target: RecyclerView.ViewHolder,
|
target: RecyclerView.ViewHolder,
|
||||||
): Boolean = current.itemViewType == target.itemViewType
|
): Boolean = current.itemViewType == target.itemViewType
|
||||||
|
|
||||||
override fun onMoved(
|
|
||||||
recyclerView: RecyclerView,
|
|
||||||
viewHolder: RecyclerView.ViewHolder,
|
|
||||||
fromPos: Int,
|
|
||||||
target: RecyclerView.ViewHolder,
|
|
||||||
toPos: Int,
|
|
||||||
x: Int,
|
|
||||||
y: Int,
|
|
||||||
) {
|
|
||||||
super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y)
|
|
||||||
viewModel.reorderCategories(fromPos, toPos)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isLongPressDragEnabled(): Boolean = false
|
override fun isLongPressDragEnabled(): Boolean = false
|
||||||
|
|
||||||
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
|
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
|
||||||
super.onSelectedChanged(viewHolder, actionState)
|
super.onSelectedChanged(viewHolder, actionState)
|
||||||
viewBinding.recyclerView.isNestedScrollingEnabled =
|
viewBinding.recyclerView.isNestedScrollingEnabled = actionState == ItemTouchHelper.ACTION_STATE_IDLE
|
||||||
actionState == ItemTouchHelper.ACTION_STATE_IDLE
|
}
|
||||||
|
|
||||||
|
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
|
||||||
|
super.clearView(recyclerView, viewHolder)
|
||||||
|
viewModel.saveOrder(adapter.items ?: return)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
package org.koitharu.kotatsu.favourites.ui.categories
|
package org.koitharu.kotatsu.favourites.ui.categories
|
||||||
|
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.plus
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
@@ -19,7 +20,6 @@ import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoryListModel
|
|||||||
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
import org.koitharu.kotatsu.list.ui.model.EmptyState
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||||
import org.koitharu.kotatsu.parsers.util.move
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -30,17 +30,9 @@ class FavouritesCategoriesViewModel @Inject constructor(
|
|||||||
|
|
||||||
private var commitJob: Job? = null
|
private var commitJob: Job? = null
|
||||||
|
|
||||||
val content = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
|
val content = repository.observeCategoriesWithCovers()
|
||||||
|
.map { it.toUiList() }
|
||||||
init {
|
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
|
||||||
launchJob(Dispatchers.Default) {
|
|
||||||
repository.observeCategoriesWithCovers()
|
|
||||||
.collectLatest {
|
|
||||||
commitJob?.join()
|
|
||||||
updateContent(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteCategories(ids: Set<Long>) {
|
fun deleteCategories(ids: Set<Long>) {
|
||||||
launchJob(Dispatchers.Default) {
|
launchJob(Dispatchers.Default) {
|
||||||
@@ -54,11 +46,17 @@ class FavouritesCategoriesViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun isEmpty(): Boolean = content.value.none { it is CategoryListModel }
|
fun isEmpty(): Boolean = content.value.none { it is CategoryListModel }
|
||||||
|
|
||||||
fun reorderCategories(oldPos: Int, newPos: Int) {
|
fun saveOrder(snapshot: List<ListModel>) {
|
||||||
val snapshot = content.requireValue().toMutableList()
|
val prevJob = commitJob
|
||||||
snapshot.move(oldPos, newPos)
|
commitJob = launchJob {
|
||||||
content.value = snapshot
|
prevJob?.cancelAndJoin()
|
||||||
commit(snapshot)
|
val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) {
|
||||||
|
(it as? CategoryListModel)?.category?.id
|
||||||
|
}
|
||||||
|
if (ids.isNotEmpty()) {
|
||||||
|
repository.reorderCategories(ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setIsVisible(ids: Set<Long>, isVisible: Boolean) {
|
fun setIsVisible(ids: Set<Long>, isVisible: Boolean) {
|
||||||
@@ -76,36 +74,21 @@ class FavouritesCategoriesViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun commit(snapshot: List<ListModel>) {
|
private fun Map<FavouriteCategory, List<Cover>>.toUiList(): List<ListModel> = map { (category, covers) ->
|
||||||
val prevJob = commitJob
|
CategoryListModel(
|
||||||
commitJob = launchJob {
|
mangaCount = covers.size,
|
||||||
prevJob?.cancelAndJoin()
|
covers = covers.take(3),
|
||||||
delay(500)
|
category = category,
|
||||||
val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) {
|
isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources,
|
||||||
(it as? CategoryListModel)?.category?.id
|
)
|
||||||
}
|
}.ifEmpty {
|
||||||
repository.reorderCategories(ids)
|
listOf(
|
||||||
yield()
|
EmptyState(
|
||||||
}
|
icon = R.drawable.ic_empty_favourites,
|
||||||
}
|
textPrimary = R.string.text_empty_holder_primary,
|
||||||
|
textSecondary = R.string.empty_favourite_categories,
|
||||||
private fun updateContent(categories: Map<FavouriteCategory, List<Cover>>) {
|
actionStringRes = 0,
|
||||||
content.value = categories.map { (category, covers) ->
|
),
|
||||||
CategoryListModel(
|
)
|
||||||
mangaCount = covers.size,
|
|
||||||
covers = covers.take(3),
|
|
||||||
category = category,
|
|
||||||
isTrackerEnabled = settings.isTrackerEnabled && AppSettings.TRACK_FAVOURITES in settings.trackSources,
|
|
||||||
)
|
|
||||||
}.ifEmpty {
|
|
||||||
listOf(
|
|
||||||
EmptyState(
|
|
||||||
icon = R.drawable.ic_empty_favourites,
|
|
||||||
textPrimary = R.string.text_empty_holder_primary,
|
|
||||||
textSecondary = R.string.empty_favourite_categories,
|
|
||||||
actionStringRes = 0,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.favourites.ui.categories.adapter
|
|||||||
|
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
import org.koitharu.kotatsu.core.ui.ReorderableListAdapter
|
||||||
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
|
import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesListListener
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
|
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
|
||||||
@@ -15,7 +15,7 @@ class CategoriesAdapter(
|
|||||||
lifecycleOwner: LifecycleOwner,
|
lifecycleOwner: LifecycleOwner,
|
||||||
onItemClickListener: FavouriteCategoriesListListener,
|
onItemClickListener: FavouriteCategoriesListListener,
|
||||||
listListener: ListStateHolderListener,
|
listListener: ListStateHolderListener,
|
||||||
) : BaseListAdapter<ListModel>() {
|
) : ReorderableListAdapter<ListModel>() {
|
||||||
|
|
||||||
init {
|
init {
|
||||||
addDelegate(ListItemType.CATEGORY_LARGE, categoryAD(coil, lifecycleOwner, onItemClickListener))
|
addDelegate(ListItemType.CATEGORY_LARGE, categoryAD(coil, lifecycleOwner, onItemClickListener))
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ fun mangaCategoryAD(
|
|||||||
}
|
}
|
||||||
|
|
||||||
bind { payloads ->
|
bind { payloads ->
|
||||||
binding.checkableImageView.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED !in payloads)
|
binding.checkableImageView.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads)
|
||||||
binding.textViewTitle.text = item.category.title
|
binding.textViewTitle.text = item.category.title
|
||||||
binding.imageViewTracker.isVisible = item.category.isTrackingEnabled && item.isTrackerEnabled
|
binding.imageViewTracker.isVisible = item.category.isTrackingEnabled && item.isTrackerEnabled
|
||||||
binding.imageViewVisible.isVisible = item.category.isVisibleInLibrary
|
binding.imageViewVisible.isVisible = item.category.isVisibleInLibrary
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.filter.ui
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.recyclerview.widget.AsyncListDiffer.ListListener
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
|
||||||
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
|
||||||
import org.koitharu.kotatsu.filter.ui.model.FilterItem
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
|
|
||||||
class FilterAdapter(
|
|
||||||
listener: OnFilterChangedListener,
|
|
||||||
listListener: ListListener<ListModel>,
|
|
||||||
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
|
||||||
|
|
||||||
init {
|
|
||||||
addDelegate(ListItemType.FILTER_SORT, filterSortDelegate(listener))
|
|
||||||
addDelegate(ListItemType.FILTER_TAG, filterTagDelegate(listener))
|
|
||||||
addDelegate(ListItemType.FILTER_TAG_MULTI, filterTagMultipleDelegate(listener))
|
|
||||||
addDelegate(ListItemType.FILTER_STATE, filterStateDelegate(listener))
|
|
||||||
addDelegate(ListItemType.HEADER, listHeaderAD(listener))
|
|
||||||
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
|
||||||
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
|
||||||
addDelegate(ListItemType.FOOTER_ERROR, filterErrorDelegate())
|
|
||||||
differ.addListListener(listListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
|
||||||
val list = items
|
|
||||||
for (i in (0..position).reversed()) {
|
|
||||||
val item = list.getOrNull(i) ?: continue
|
|
||||||
if (item is FilterItem.Tag) {
|
|
||||||
return item.tag.title.firstOrNull()?.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.filter.ui
|
|
||||||
|
|
||||||
import android.widget.TextView
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.model.titleResId
|
|
||||||
import org.koitharu.kotatsu.core.ui.model.titleRes
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.setChecked
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
|
|
||||||
import org.koitharu.kotatsu.databinding.ItemCheckableSingleBinding
|
|
||||||
import org.koitharu.kotatsu.filter.ui.model.FilterItem
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
|
|
||||||
fun filterSortDelegate(
|
|
||||||
listener: OnFilterChangedListener,
|
|
||||||
) = adapterDelegateViewBinding<FilterItem.Sort, ListModel, ItemCheckableSingleBinding>(
|
|
||||||
{ layoutInflater, parent -> ItemCheckableSingleBinding.inflate(layoutInflater, parent, false) },
|
|
||||||
) {
|
|
||||||
|
|
||||||
itemView.setOnClickListener {
|
|
||||||
listener.onSortItemClick(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
bind { payloads ->
|
|
||||||
binding.root.setText(item.order.titleRes)
|
|
||||||
binding.root.setChecked(item.isSelected, payloads.isNotEmpty())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun filterStateDelegate(
|
|
||||||
listener: OnFilterChangedListener,
|
|
||||||
) = adapterDelegateViewBinding<FilterItem.State, ListModel, ItemCheckableMultipleBinding>(
|
|
||||||
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
|
|
||||||
) {
|
|
||||||
|
|
||||||
itemView.setOnClickListener {
|
|
||||||
listener.onStateItemClick(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
bind { payloads ->
|
|
||||||
binding.root.setText(item.state.titleResId)
|
|
||||||
binding.root.setChecked(item.isChecked, payloads.isNotEmpty())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun filterTagDelegate(
|
|
||||||
listener: OnFilterChangedListener,
|
|
||||||
) = adapterDelegateViewBinding<FilterItem.Tag, ListModel, ItemCheckableSingleBinding>(
|
|
||||||
{ layoutInflater, parent -> ItemCheckableSingleBinding.inflate(layoutInflater, parent, false) },
|
|
||||||
on = { item, _, _ -> item is FilterItem.Tag && !item.isMultiple },
|
|
||||||
) {
|
|
||||||
|
|
||||||
itemView.setOnClickListener {
|
|
||||||
listener.onTagItemClick(item, isFromChip = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
bind { payloads ->
|
|
||||||
binding.root.text = item.tag.title
|
|
||||||
binding.root.setChecked(item.isChecked, payloads.isNotEmpty())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun filterTagMultipleDelegate(
|
|
||||||
listener: OnFilterChangedListener,
|
|
||||||
) = adapterDelegateViewBinding<FilterItem.Tag, ListModel, ItemCheckableMultipleBinding>(
|
|
||||||
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
|
|
||||||
on = { item, _, _ -> item is FilterItem.Tag && item.isMultiple },
|
|
||||||
) {
|
|
||||||
|
|
||||||
itemView.setOnClickListener {
|
|
||||||
listener.onTagItemClick(item, isFromChip = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
bind { payloads ->
|
|
||||||
binding.root.text = item.tag.title
|
|
||||||
binding.root.setChecked(item.isChecked, payloads.isNotEmpty())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun filterErrorDelegate() = adapterDelegate<FilterItem.Error, ListModel>(R.layout.item_sources_empty) {
|
|
||||||
|
|
||||||
bind {
|
|
||||||
(itemView as TextView).setText(item.textResId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +1,50 @@
|
|||||||
package org.koitharu.kotatsu.filter.ui
|
package org.koitharu.kotatsu.filter.ui
|
||||||
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.annotation.WorkerThread
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import dagger.hilt.android.ViewModelLifecycle
|
import dagger.hilt.android.ViewModelLifecycle
|
||||||
import dagger.hilt.android.scopes.ViewModelScoped
|
import dagger.hilt.android.scopes.ViewModelScoped
|
||||||
import kotlinx.coroutines.CoroutineStart
|
import kotlinx.coroutines.CoroutineStart
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.plus
|
import kotlinx.coroutines.plus
|
||||||
import org.koitharu.kotatsu.R
|
import org.koitharu.kotatsu.R
|
||||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
import org.koitharu.kotatsu.core.parser.MangaRepository
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||||
|
import org.koitharu.kotatsu.core.util.LocaleComparator
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.asArrayList
|
||||||
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
|
import org.koitharu.kotatsu.core.util.ext.lifecycleScope
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.core.util.ext.require
|
import org.koitharu.kotatsu.core.util.ext.require
|
||||||
import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal
|
|
||||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||||
import org.koitharu.kotatsu.filter.ui.model.FilterItem
|
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||||
|
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ErrorFooter
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
import org.koitharu.kotatsu.list.ui.model.ListHeader
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
import org.koitharu.kotatsu.list.ui.model.LoadingFooter
|
||||||
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.toErrorFooter
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
import org.koitharu.kotatsu.parsers.util.SuspendLazy
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
||||||
@@ -55,16 +66,84 @@ class FilterCoordinator @Inject constructor(
|
|||||||
|
|
||||||
private val coroutineScope = lifecycle.lifecycleScope
|
private val coroutineScope = lifecycle.lifecycleScope
|
||||||
private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE))
|
private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE))
|
||||||
private val currentState =
|
private val currentState = MutableStateFlow(
|
||||||
MutableStateFlow(MangaListFilter.Advanced(repository.defaultSortOrder, emptySet(), null, emptySet()))
|
MangaListFilter.Advanced(repository.defaultSortOrder, emptySet(), null, emptySet()),
|
||||||
private var searchQuery = MutableStateFlow("")
|
)
|
||||||
private val localTags = SuspendLazy {
|
private val localTags = SuspendLazy {
|
||||||
dataRepository.findTags(repository.source)
|
dataRepository.findTags(repository.source)
|
||||||
}
|
}
|
||||||
private var availableTagsDeferred = loadTagsAsync()
|
private var availableTagsDeferred = loadTagsAsync()
|
||||||
|
private var availableLocalesDeferred = loadLocalesAsync()
|
||||||
|
private var allTagsLoadJob: Job? = null
|
||||||
|
|
||||||
override val filterItems: StateFlow<List<ListModel>> = getItemsFlow()
|
override val allTags = MutableStateFlow<List<ListModel>>(listOf(LoadingState))
|
||||||
.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState))
|
get() {
|
||||||
|
if (allTagsLoadJob == null || field.value.any { it is ErrorFooter }) {
|
||||||
|
loadAllTags()
|
||||||
|
}
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
|
||||||
|
override val filterTags: StateFlow<FilterProperty<MangaTag>> = combine(
|
||||||
|
currentState.distinctUntilChangedBy { it.tags },
|
||||||
|
getTopTagsAsFlow(currentState.map { it.tags }, 16),
|
||||||
|
) { state, tags ->
|
||||||
|
FilterProperty(
|
||||||
|
availableItems = tags.items.asArrayList(),
|
||||||
|
selectedItems = state.tags,
|
||||||
|
isLoading = tags.isLoading,
|
||||||
|
error = tags.error,
|
||||||
|
)
|
||||||
|
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
|
||||||
|
|
||||||
|
override val filterSortOrder: StateFlow<FilterProperty<SortOrder>> = combine(
|
||||||
|
currentState.distinctUntilChangedBy { it.sortOrder },
|
||||||
|
flowOf(repository.sortOrders),
|
||||||
|
) { state, orders ->
|
||||||
|
FilterProperty(
|
||||||
|
availableItems = orders.sortedBy { it.ordinal },
|
||||||
|
selectedItems = setOf(state.sortOrder),
|
||||||
|
isLoading = false,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
|
||||||
|
|
||||||
|
override val filterState: StateFlow<FilterProperty<MangaState>> = combine(
|
||||||
|
currentState.distinctUntilChangedBy { it.states },
|
||||||
|
flowOf(repository.states),
|
||||||
|
) { state, states ->
|
||||||
|
FilterProperty(
|
||||||
|
availableItems = states.sortedBy { it.ordinal },
|
||||||
|
selectedItems = state.states,
|
||||||
|
isLoading = false,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
|
||||||
|
|
||||||
|
override val filterLocale: StateFlow<FilterProperty<Locale?>> = combine(
|
||||||
|
currentState.distinctUntilChangedBy { it.locale },
|
||||||
|
getLocalesAsFlow(),
|
||||||
|
) { state, locales ->
|
||||||
|
val list = if (locales.items.isNotEmpty()) {
|
||||||
|
val l = ArrayList<Locale?>(locales.items.size + 1)
|
||||||
|
l.add(null)
|
||||||
|
l.addAll(locales.items)
|
||||||
|
try {
|
||||||
|
l.sortWith(nullsFirst(LocaleComparator()))
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
}
|
||||||
|
l
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
FilterProperty(
|
||||||
|
availableItems = list,
|
||||||
|
selectedItems = setOf(state.locale),
|
||||||
|
isLoading = locales.isLoading,
|
||||||
|
error = locales.error,
|
||||||
|
)
|
||||||
|
}.stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, loadingProperty())
|
||||||
|
|
||||||
override val header: StateFlow<FilterHeaderModel> = getHeaderFlow().stateIn(
|
override val header: StateFlow<FilterHeaderModel> = getHeaderFlow().stateIn(
|
||||||
scope = coroutineScope + Dispatchers.Default,
|
scope = coroutineScope + Dispatchers.Default,
|
||||||
@@ -72,49 +151,52 @@ class FilterCoordinator @Inject constructor(
|
|||||||
initialValue = FilterHeaderModel(
|
initialValue = FilterHeaderModel(
|
||||||
chips = emptyList(),
|
chips = emptyList(),
|
||||||
sortOrder = repository.defaultSortOrder,
|
sortOrder = repository.defaultSortOrder,
|
||||||
hasSelectedTags = false,
|
isFilterApplied = false,
|
||||||
allowMultipleTags = repository.isMultipleTagsSupported,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
|
||||||
observeState()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun applyFilter(tags: Set<MangaTag>) {
|
override fun applyFilter(tags: Set<MangaTag>) {
|
||||||
setTags(tags)
|
setTags(tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSortItemClick(item: FilterItem.Sort) {
|
override fun setSortOrder(value: SortOrder) {
|
||||||
currentState.update { oldValue ->
|
currentState.update { oldValue ->
|
||||||
oldValue.copy(sortOrder = item.order)
|
oldValue.copy(sortOrder = value)
|
||||||
}
|
}
|
||||||
repository.defaultSortOrder = item.order
|
repository.defaultSortOrder = value
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTagItemClick(item: FilterItem.Tag, isFromChip: Boolean) {
|
override fun setLanguage(value: Locale?) {
|
||||||
currentState.update { oldValue ->
|
currentState.update { oldValue ->
|
||||||
val newTags = if (!item.isMultiple) {
|
oldValue.copy(locale = value)
|
||||||
if (isFromChip && item.isChecked) {
|
}
|
||||||
emptySet()
|
}
|
||||||
|
|
||||||
|
override fun setTag(value: MangaTag, addOrRemove: Boolean) {
|
||||||
|
currentState.update { oldValue ->
|
||||||
|
val newTags = if (repository.isMultipleTagsSupported) {
|
||||||
|
if (addOrRemove) {
|
||||||
|
oldValue.tags + value
|
||||||
} else {
|
} else {
|
||||||
setOf(item.tag)
|
oldValue.tags - value
|
||||||
}
|
}
|
||||||
} else if (item.isChecked) {
|
|
||||||
oldValue.tags - item.tag
|
|
||||||
} else {
|
} else {
|
||||||
oldValue.tags + item.tag
|
if (addOrRemove) {
|
||||||
|
setOf(value)
|
||||||
|
} else {
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
oldValue.copy(tags = newTags)
|
oldValue.copy(tags = newTags)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStateItemClick(item: FilterItem.State) {
|
override fun setState(value: MangaState, addOrRemove: Boolean) {
|
||||||
currentState.update { oldValue ->
|
currentState.update { oldValue ->
|
||||||
val newStates = if (item.isChecked) {
|
val newStates = if (addOrRemove) {
|
||||||
oldValue.states - item.state
|
oldValue.states + value
|
||||||
} else {
|
} else {
|
||||||
oldValue.states + item.state
|
oldValue.states - value
|
||||||
}
|
}
|
||||||
oldValue.copy(states = newStates)
|
oldValue.copy(states = newStates)
|
||||||
}
|
}
|
||||||
@@ -125,7 +207,7 @@ class FilterCoordinator @Inject constructor(
|
|||||||
oldValue.copy(
|
oldValue.copy(
|
||||||
sortOrder = oldValue.sortOrder,
|
sortOrder = oldValue.sortOrder,
|
||||||
tags = if (item.payload == R.string.genres) emptySet() else oldValue.tags,
|
tags = if (item.payload == R.string.genres) emptySet() else oldValue.tags,
|
||||||
locale = null,
|
locale = if (item.payload == R.string.language) null else oldValue.locale,
|
||||||
states = if (item.payload == R.string.state) emptySet() else oldValue.states,
|
states = if (item.payload == R.string.state) emptySet() else oldValue.states,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -135,7 +217,7 @@ class FilterCoordinator @Inject constructor(
|
|||||||
if (!availableTagsDeferred.isCompleted) {
|
if (!availableTagsDeferred.isCompleted) {
|
||||||
emit(emptySet())
|
emit(emptySet())
|
||||||
}
|
}
|
||||||
emit(availableTagsDeferred.await())
|
emit(availableTagsDeferred.await().getOrNull())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeState() = currentState.asStateFlow()
|
fun observeState() = currentState.asStateFlow()
|
||||||
@@ -154,10 +236,6 @@ class FilterCoordinator @Inject constructor(
|
|||||||
|
|
||||||
fun snapshot() = currentState.value
|
fun snapshot() = currentState.value
|
||||||
|
|
||||||
fun performSearch(query: String) {
|
|
||||||
searchQuery.value = query
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getHeaderFlow() = combine(
|
private fun getHeaderFlow() = combine(
|
||||||
observeState(),
|
observeState(),
|
||||||
observeAvailableTags(),
|
observeAvailableTags(),
|
||||||
@@ -166,28 +244,46 @@ class FilterCoordinator @Inject constructor(
|
|||||||
FilterHeaderModel(
|
FilterHeaderModel(
|
||||||
chips = chips,
|
chips = chips,
|
||||||
sortOrder = state.sortOrder,
|
sortOrder = state.sortOrder,
|
||||||
hasSelectedTags = state.tags.isNotEmpty(),
|
isFilterApplied = !state.isEmpty(),
|
||||||
allowMultipleTags = repository.isMultipleTagsSupported,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getItemsFlow() = combine(
|
|
||||||
getTagsAsFlow(),
|
|
||||||
currentState,
|
|
||||||
searchQuery,
|
|
||||||
) { tags, state, query ->
|
|
||||||
buildFilterList(tags, state, query)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getTagsAsFlow() = flow {
|
private fun getTagsAsFlow() = flow {
|
||||||
val localTags = localTags.get()
|
val localTags = localTags.get()
|
||||||
emit(TagsWrapper(localTags, isLoading = true, isError = false))
|
emit(PendingData(localTags, isLoading = true, error = null))
|
||||||
val remoteTags = tryLoadTags()
|
tryLoadTags()
|
||||||
if (remoteTags == null) {
|
.onSuccess { remoteTags ->
|
||||||
emit(TagsWrapper(localTags, isLoading = false, isError = true))
|
emit(PendingData(mergeTags(remoteTags, localTags), isLoading = false, error = null))
|
||||||
} else {
|
}.onFailure {
|
||||||
emit(TagsWrapper(mergeTags(remoteTags, localTags), isLoading = false, isError = false))
|
emit(PendingData(localTags, isLoading = false, error = it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getLocalesAsFlow(): Flow<PendingData<Locale>> = flow {
|
||||||
|
emit(PendingData(emptySet(), isLoading = true, error = null))
|
||||||
|
tryLoadLocales()
|
||||||
|
.onSuccess { locales ->
|
||||||
|
emit(PendingData(locales, isLoading = false, error = null))
|
||||||
|
}.onFailure {
|
||||||
|
emit(PendingData(emptySet(), isLoading = false, error = it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTopTagsAsFlow(selectedTags: Flow<Set<MangaTag>>, limit: Int): Flow<PendingData<MangaTag>> = combine(
|
||||||
|
selectedTags.map {
|
||||||
|
if (it.isEmpty()) {
|
||||||
|
searchRepository.getTagsSuggestion("", limit, repository.source)
|
||||||
|
} else {
|
||||||
|
searchRepository.getTagsSuggestion(it).take(limit)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getTagsAsFlow(),
|
||||||
|
) { suggested, all ->
|
||||||
|
val res = suggested.toMutableList()
|
||||||
|
if (res.size < limit) {
|
||||||
|
res.addAll(all.items.shuffled().take(limit - res.size))
|
||||||
}
|
}
|
||||||
|
PendingData(res, all.isLoading, all.error.takeIf { res.size < limit })
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun createChipsList(
|
private suspend fun createChipsList(
|
||||||
@@ -237,84 +333,40 @@ class FilterCoordinator @Inject constructor(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@WorkerThread
|
private suspend fun tryLoadTags(): Result<Set<MangaTag>> {
|
||||||
private fun buildFilterList(
|
|
||||||
allTags: TagsWrapper,
|
|
||||||
state: MangaListFilter.Advanced,
|
|
||||||
query: String,
|
|
||||||
): List<ListModel> {
|
|
||||||
val sortOrders = repository.sortOrders.sortedByOrdinal()
|
|
||||||
val states = repository.states
|
|
||||||
val tags = mergeTags(state.tags, allTags.tags).toList()
|
|
||||||
val list = ArrayList<ListModel>(tags.size + states.size + sortOrders.size + 4)
|
|
||||||
val isMultiTag = repository.isMultipleTagsSupported
|
|
||||||
if (query.isEmpty()) {
|
|
||||||
if (sortOrders.isNotEmpty()) {
|
|
||||||
list.add(ListHeader(R.string.sort_order))
|
|
||||||
sortOrders.mapTo(list) {
|
|
||||||
FilterItem.Sort(it, isSelected = it == state.sortOrder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (states.isNotEmpty()) {
|
|
||||||
list.add(
|
|
||||||
ListHeader(
|
|
||||||
textRes = R.string.state,
|
|
||||||
buttonTextRes = if (state.states.isEmpty()) 0 else R.string.reset,
|
|
||||||
payload = R.string.state,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
states.mapTo(list) {
|
|
||||||
FilterItem.State(it, isChecked = it in state.states)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (allTags.isLoading || allTags.isError || tags.isNotEmpty()) {
|
|
||||||
list.add(
|
|
||||||
ListHeader(
|
|
||||||
textRes = R.string.genres,
|
|
||||||
buttonTextRes = if (state.tags.isEmpty()) 0 else R.string.reset,
|
|
||||||
payload = R.string.genres,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
tags.mapTo(list) {
|
|
||||||
FilterItem.Tag(it, isMultiple = isMultiTag, isChecked = it in state.tags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (allTags.isError) {
|
|
||||||
list.add(FilterItem.Error(R.string.filter_load_error))
|
|
||||||
} else if (allTags.isLoading) {
|
|
||||||
list.add(LoadingFooter())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tags.mapNotNullTo(list) {
|
|
||||||
if (it.title.contains(query, ignoreCase = true)) {
|
|
||||||
FilterItem.Tag(it, isMultiple = isMultiTag, isChecked = it in state.tags)
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (list.isEmpty()) {
|
|
||||||
list.add(FilterItem.Error(R.string.nothing_found))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun tryLoadTags(): Set<MangaTag>? {
|
|
||||||
val shouldRetryOnError = availableTagsDeferred.isCompleted
|
val shouldRetryOnError = availableTagsDeferred.isCompleted
|
||||||
val result = availableTagsDeferred.await()
|
val result = availableTagsDeferred.await()
|
||||||
if (result == null && shouldRetryOnError) {
|
if (result.isFailure && shouldRetryOnError) {
|
||||||
availableTagsDeferred = loadTagsAsync()
|
availableTagsDeferred = loadTagsAsync()
|
||||||
return availableTagsDeferred.await()
|
return availableTagsDeferred.await()
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun tryLoadLocales(): Result<Set<Locale>> {
|
||||||
|
val shouldRetryOnError = availableLocalesDeferred.isCompleted
|
||||||
|
val result = availableLocalesDeferred.await()
|
||||||
|
if (result.isFailure && shouldRetryOnError) {
|
||||||
|
availableLocalesDeferred = loadLocalesAsync()
|
||||||
|
return availableLocalesDeferred.await()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadTagsAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) {
|
private fun loadTagsAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) {
|
||||||
runCatchingCancellable {
|
runCatchingCancellable {
|
||||||
repository.getTags()
|
repository.getTags()
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
error.printStackTraceDebug()
|
error.printStackTraceDebug()
|
||||||
}.getOrNull()
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadLocalesAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) {
|
||||||
|
runCatchingCancellable {
|
||||||
|
repository.getLocales()
|
||||||
|
}.onFailure { error ->
|
||||||
|
error.printStackTraceDebug()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> {
|
private fun mergeTags(primary: Set<MangaTag>, secondary: Set<MangaTag>): Set<MangaTag> {
|
||||||
@@ -324,12 +376,41 @@ class FilterCoordinator @Inject constructor(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class TagsWrapper(
|
private fun loadAllTags() {
|
||||||
val tags: Set<MangaTag>,
|
val prevJob = allTagsLoadJob
|
||||||
|
allTagsLoadJob = coroutineScope.launch(Dispatchers.Default) {
|
||||||
|
runCatchingCancellable {
|
||||||
|
prevJob?.cancelAndJoin()
|
||||||
|
appendTagsList(localTags.get(), isLoading = true)
|
||||||
|
appendTagsList(availableTagsDeferred.await().getOrThrow(), isLoading = false)
|
||||||
|
}.onFailure { e ->
|
||||||
|
allTags.value = allTags.value.filterIsInstance<TagCatalogItem>() + e.toErrorFooter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appendTagsList(newTags: Collection<MangaTag>, isLoading: Boolean) = allTags.update { oldList ->
|
||||||
|
val oldTags = oldList.filterIsInstance<TagCatalogItem>()
|
||||||
|
buildList(oldTags.size + newTags.size + if (isLoading) 1 else 0) {
|
||||||
|
addAll(oldTags)
|
||||||
|
newTags.mapTo(this) { TagCatalogItem(it, isChecked = false) }
|
||||||
|
val tempSet = HashSet<MangaTag>(size)
|
||||||
|
removeAll { x -> x is TagCatalogItem && !tempSet.add(x.tag) }
|
||||||
|
sortBy { (it as TagCatalogItem).tag.title }
|
||||||
|
if (isLoading) {
|
||||||
|
add(LoadingFooter())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class PendingData<T>(
|
||||||
|
val items: Collection<T>,
|
||||||
val isLoading: Boolean,
|
val isLoading: Boolean,
|
||||||
val isError: Boolean,
|
val error: Throwable?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun <T> loadingProperty() = FilterProperty<T>(emptyList(), emptySet(), true, null)
|
||||||
|
|
||||||
private class TagTitleComparator(lc: String?) : Comparator<MangaTag> {
|
private class TagTitleComparator(lc: String?) : Comparator<MangaTag> {
|
||||||
|
|
||||||
private val collator = lc?.let { Collator.getInstance(Locale(it)) }
|
private val collator = lc?.let { Collator.getInstance(Locale(it)) }
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
|||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding
|
import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding
|
||||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||||
import org.koitharu.kotatsu.filter.ui.model.FilterItem
|
import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
import com.google.android.material.R as materialR
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
@@ -37,10 +37,9 @@ class FilterHeaderFragment : BaseFragment<FragmentFilterHeaderBinding>(), ChipsV
|
|||||||
override fun onChipClick(chip: Chip, data: Any?) {
|
override fun onChipClick(chip: Chip, data: Any?) {
|
||||||
val tag = data as? MangaTag
|
val tag = data as? MangaTag
|
||||||
if (tag == null) {
|
if (tag == null) {
|
||||||
FilterSheetFragment.show(parentFragmentManager)
|
TagsCatalogSheet.show(parentFragmentManager)
|
||||||
} else {
|
} else {
|
||||||
val filterItem = FilterItem.Tag(tag, filter.header.value.allowMultipleTags, !chip.isChecked)
|
filter.setTag(tag, chip.isChecked)
|
||||||
filter.onTagItemClick(filterItem, isFromChip = true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.filter.ui
|
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.fragment.app.FragmentManager
|
|
||||||
import androidx.recyclerview.widget.AsyncListDiffer
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior
|
|
||||||
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
|
|
||||||
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.observe
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
|
||||||
import org.koitharu.kotatsu.databinding.SheetFilterBinding
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
|
|
||||||
class FilterSheetFragment :
|
|
||||||
BaseAdaptiveSheet<SheetFilterBinding>(),
|
|
||||||
AdaptiveSheetCallback,
|
|
||||||
AsyncListDiffer.ListListener<ListModel> {
|
|
||||||
|
|
||||||
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
|
|
||||||
return SheetFilterBinding.inflate(inflater, container, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
|
|
||||||
super.onViewBindingCreated(binding, savedInstanceState)
|
|
||||||
val filter = (requireActivity() as FilterOwner).filter
|
|
||||||
addSheetCallback(this)
|
|
||||||
val adapter = FilterAdapter(filter, this)
|
|
||||||
binding.recyclerView.adapter = adapter
|
|
||||||
filter.filterItems.observe(viewLifecycleOwner, adapter)
|
|
||||||
binding.recyclerView.addItemDecoration(TypedListSpacingDecoration(binding.root.context, true))
|
|
||||||
|
|
||||||
if (dialog == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
||||||
binding.recyclerView.scrollIndicators = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCurrentListChanged(previousList: MutableList<ListModel>, currentList: MutableList<ListModel>) {
|
|
||||||
if (currentList.size > previousList.size && view != null) {
|
|
||||||
(requireViewBinding().recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(0, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStateChanged(sheet: View, newState: Int) {
|
|
||||||
viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
private const val TAG = "FilterBottomSheet"
|
|
||||||
|
|
||||||
fun show(fm: FragmentManager) = FilterSheetFragment().showDistinct(fm, TAG)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,24 @@ package org.koitharu.kotatsu.filter.ui
|
|||||||
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
|
||||||
|
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
interface MangaFilter : OnFilterChangedListener {
|
interface MangaFilter : OnFilterChangedListener {
|
||||||
|
|
||||||
val filterItems: StateFlow<List<ListModel>>
|
val allTags: StateFlow<List<ListModel>>
|
||||||
|
|
||||||
|
val filterTags: StateFlow<FilterProperty<MangaTag>>
|
||||||
|
|
||||||
|
val filterSortOrder: StateFlow<FilterProperty<SortOrder>>
|
||||||
|
|
||||||
|
val filterState: StateFlow<FilterProperty<MangaState>>
|
||||||
|
|
||||||
|
val filterLocale: StateFlow<FilterProperty<Locale?>>
|
||||||
|
|
||||||
val header: StateFlow<FilterHeaderModel>
|
val header: StateFlow<FilterHeaderModel>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
package org.koitharu.kotatsu.filter.ui
|
package org.koitharu.kotatsu.filter.ui
|
||||||
|
|
||||||
import org.koitharu.kotatsu.filter.ui.model.FilterItem
|
|
||||||
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
interface OnFilterChangedListener : ListHeaderClickListener {
|
interface OnFilterChangedListener : ListHeaderClickListener {
|
||||||
|
|
||||||
fun onSortItemClick(item: FilterItem.Sort)
|
fun setSortOrder(value: SortOrder)
|
||||||
|
|
||||||
fun onTagItemClick(item: FilterItem.Tag, isFromChip: Boolean)
|
fun setLanguage(value: Locale?)
|
||||||
|
|
||||||
fun onStateItemClick(item: FilterItem.State)
|
fun setTag(value: MangaTag, addOrRemove: Boolean)
|
||||||
|
|
||||||
|
fun setState(value: MangaState, addOrRemove: Boolean)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,33 +3,12 @@ package org.koitharu.kotatsu.filter.ui.model
|
|||||||
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
|
||||||
class FilterHeaderModel(
|
data class FilterHeaderModel(
|
||||||
val chips: Collection<ChipsView.ChipModel>,
|
val chips: Collection<ChipsView.ChipModel>,
|
||||||
val sortOrder: SortOrder?,
|
val sortOrder: SortOrder?,
|
||||||
val hasSelectedTags: Boolean,
|
val isFilterApplied: Boolean,
|
||||||
val allowMultipleTags: Boolean,
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val textSummary: String
|
val textSummary: String
|
||||||
get() = chips.mapNotNull { if (it.isChecked) it.title else null }.joinToString()
|
get() = chips.mapNotNull { if (it.isChecked) it.title else null }.joinToString()
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
|
||||||
if (this === other) return true
|
|
||||||
if (javaClass != other?.javaClass) return false
|
|
||||||
|
|
||||||
other as FilterHeaderModel
|
|
||||||
|
|
||||||
if (chips != other.chips) return false
|
|
||||||
if (allowMultipleTags != other.allowMultipleTags) return false
|
|
||||||
return sortOrder == other.sortOrder
|
|
||||||
// Not need to check hasSelectedTags
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
|
||||||
var result = chips.hashCode()
|
|
||||||
result = 31 * result + allowMultipleTags.hashCode()
|
|
||||||
result = 31 * result + (sortOrder?.hashCode() ?: 0)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.filter.ui.model
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
|
||||||
|
|
||||||
sealed interface FilterItem : ListModel {
|
|
||||||
|
|
||||||
data class Sort(
|
|
||||||
val order: SortOrder,
|
|
||||||
val isSelected: Boolean,
|
|
||||||
) : FilterItem {
|
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
|
||||||
return other is Sort && other.order == order
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getChangePayload(previousState: ListModel): Any? {
|
|
||||||
return if (previousState is Sort && previousState.isSelected != isSelected) {
|
|
||||||
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
|
|
||||||
} else {
|
|
||||||
super.getChangePayload(previousState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Tag(
|
|
||||||
val tag: MangaTag,
|
|
||||||
val isMultiple: Boolean,
|
|
||||||
val isChecked: Boolean,
|
|
||||||
) : FilterItem {
|
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
|
||||||
return other is Tag && other.isMultiple == isMultiple && other.tag == tag
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getChangePayload(previousState: ListModel): Any? {
|
|
||||||
return if (previousState is Tag && previousState.isChecked != isChecked) {
|
|
||||||
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
|
|
||||||
} else {
|
|
||||||
super.getChangePayload(previousState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class State(
|
|
||||||
val state: MangaState,
|
|
||||||
val isChecked: Boolean
|
|
||||||
) : FilterItem {
|
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
|
||||||
return other is State && other.state == state
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getChangePayload(previousState: ListModel): Any? {
|
|
||||||
return if (previousState is State && previousState.isChecked != isChecked) {
|
|
||||||
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
|
|
||||||
} else {
|
|
||||||
super.getChangePayload(previousState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class Error(
|
|
||||||
@StringRes val textResId: Int,
|
|
||||||
) : FilterItem {
|
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
|
||||||
return other is Error && textResId == other.textResId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.koitharu.kotatsu.filter.ui.model
|
||||||
|
|
||||||
|
data class FilterProperty<T>(
|
||||||
|
val availableItems: List<T>,
|
||||||
|
val selectedItems: Set<T>,
|
||||||
|
val isLoading: Boolean,
|
||||||
|
val error: Throwable?,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun isEmpty(): Boolean = availableItems.isEmpty()
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package org.koitharu.kotatsu.filter.ui.model
|
||||||
|
|
||||||
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
|
||||||
|
data class TagCatalogItem(
|
||||||
|
val tag: MangaTag,
|
||||||
|
val isChecked: Boolean,
|
||||||
|
) : ListModel {
|
||||||
|
|
||||||
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
|
return other is TagCatalogItem && other.tag == tag
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChangePayload(previousState: ListModel): Any? {
|
||||||
|
return if (previousState is TagCatalogItem && previousState.isChecked != isChecked) {
|
||||||
|
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
|
||||||
|
} else {
|
||||||
|
super.getChangePayload(previousState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package org.koitharu.kotatsu.filter.ui.sheet
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.AdapterView
|
||||||
|
import android.widget.ArrayAdapter
|
||||||
|
import androidx.core.view.isGone
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import com.google.android.material.chip.Chip
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.model.titleResId
|
||||||
|
import org.koitharu.kotatsu.core.ui.model.titleRes
|
||||||
|
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||||
|
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
||||||
|
import org.koitharu.kotatsu.databinding.SheetFilterBinding
|
||||||
|
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||||
|
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
|
||||||
|
import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaState
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
|
import org.koitharu.kotatsu.parsers.model.SortOrder
|
||||||
|
import org.koitharu.kotatsu.parsers.util.toTitleCase
|
||||||
|
import java.util.Locale
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
class FilterSheetFragment :
|
||||||
|
BaseAdaptiveSheet<SheetFilterBinding>(), AdapterView.OnItemSelectedListener, ChipsView.OnChipClickListener {
|
||||||
|
|
||||||
|
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetFilterBinding {
|
||||||
|
return SheetFilterBinding.inflate(inflater, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
|
if (dialog == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
binding.scrollView.scrollIndicators = 0
|
||||||
|
}
|
||||||
|
val filter = requireFilter()
|
||||||
|
filter.filterSortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)
|
||||||
|
filter.filterLocale.observe(viewLifecycleOwner, this::onLocaleChanged)
|
||||||
|
filter.filterTags.observe(viewLifecycleOwner, this::onTagsChanged)
|
||||||
|
filter.filterState.observe(viewLifecycleOwner, this::onStateChanged)
|
||||||
|
|
||||||
|
binding.spinnerLocale.onItemSelectedListener = this
|
||||||
|
binding.spinnerOrder.onItemSelectedListener = this
|
||||||
|
binding.chipsState.onChipClickListener = this
|
||||||
|
binding.chipsGenres.onChipClickListener = this
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
|
||||||
|
val filter = requireFilter()
|
||||||
|
when (parent.id) {
|
||||||
|
R.id.spinner_order -> filter.setSortOrder(filter.filterSortOrder.value.availableItems[position])
|
||||||
|
R.id.spinner_locale -> filter.setLanguage(filter.filterLocale.value.availableItems[position])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||||
|
|
||||||
|
override fun onChipClick(chip: Chip, data: Any?) {
|
||||||
|
val filter = requireFilter()
|
||||||
|
when (data) {
|
||||||
|
is MangaState -> filter.setState(data, chip.isChecked)
|
||||||
|
is MangaTag -> filter.setTag(data, chip.isChecked)
|
||||||
|
null -> TagsCatalogSheet.show(childFragmentManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onSortOrderChanged(value: FilterProperty<SortOrder>) {
|
||||||
|
val b = viewBinding ?: return
|
||||||
|
b.textViewOrderTitle.isGone = value.isEmpty()
|
||||||
|
b.cardOrder.isGone = value.isEmpty()
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val selected = value.selectedItems.single()
|
||||||
|
b.spinnerOrder.adapter = ArrayAdapter(
|
||||||
|
b.spinnerOrder.context,
|
||||||
|
android.R.layout.simple_spinner_dropdown_item,
|
||||||
|
android.R.id.text1,
|
||||||
|
value.availableItems.map { b.spinnerOrder.context.getString(it.titleRes) },
|
||||||
|
)
|
||||||
|
val selectedIndex = value.availableItems.indexOf(selected)
|
||||||
|
if (selectedIndex >= 0) {
|
||||||
|
b.spinnerOrder.setSelection(selectedIndex, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onLocaleChanged(value: FilterProperty<Locale?>) {
|
||||||
|
val b = viewBinding ?: return
|
||||||
|
b.textViewLocaleTitle.isGone = value.isEmpty()
|
||||||
|
b.cardLocale.isGone = value.isEmpty()
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val selected = value.selectedItems.singleOrNull()
|
||||||
|
b.spinnerLocale.adapter = ArrayAdapter(
|
||||||
|
b.spinnerLocale.context,
|
||||||
|
android.R.layout.simple_spinner_dropdown_item,
|
||||||
|
android.R.id.text1,
|
||||||
|
value.availableItems.map {
|
||||||
|
it?.getDisplayLanguage(it)?.toTitleCase(it)
|
||||||
|
?: b.spinnerLocale.context.getString(R.string.various_languages)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val selectedIndex = value.availableItems.indexOf(selected)
|
||||||
|
if (selectedIndex >= 0) {
|
||||||
|
b.spinnerLocale.setSelection(selectedIndex, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onTagsChanged(value: FilterProperty<MangaTag>) {
|
||||||
|
val b = viewBinding ?: return
|
||||||
|
b.textViewGenresTitle.isGone = value.isEmpty()
|
||||||
|
b.chipsGenres.isGone = value.isEmpty()
|
||||||
|
b.textViewGenresHint.textAndVisible = value.error?.getDisplayMessage(resources)
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val chips = ArrayList<ChipsView.ChipModel>(value.selectedItems.size + value.availableItems.size + 1)
|
||||||
|
value.selectedItems.mapTo(chips) { tag ->
|
||||||
|
ChipsView.ChipModel(
|
||||||
|
tint = 0,
|
||||||
|
title = tag.title,
|
||||||
|
icon = 0,
|
||||||
|
isCheckable = true,
|
||||||
|
isChecked = true,
|
||||||
|
data = tag,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
value.availableItems.mapNotNullTo(chips) { tag ->
|
||||||
|
if (tag !in value.selectedItems) {
|
||||||
|
ChipsView.ChipModel(
|
||||||
|
tint = 0,
|
||||||
|
title = tag.title,
|
||||||
|
icon = 0,
|
||||||
|
isCheckable = true,
|
||||||
|
isChecked = false,
|
||||||
|
data = tag,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chips.add(
|
||||||
|
ChipsView.ChipModel(
|
||||||
|
tint = 0,
|
||||||
|
title = getString(R.string.more),
|
||||||
|
icon = materialR.drawable.abc_ic_menu_overflow_material,
|
||||||
|
isCheckable = false,
|
||||||
|
isChecked = false,
|
||||||
|
data = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
b.chipsGenres.setChips(chips)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onStateChanged(value: FilterProperty<MangaState>) {
|
||||||
|
val b = viewBinding ?: return
|
||||||
|
b.textViewStateTitle.isGone = value.isEmpty()
|
||||||
|
b.chipsState.isGone = value.isEmpty()
|
||||||
|
if (value.isEmpty()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val chips = value.availableItems.map { state ->
|
||||||
|
ChipsView.ChipModel(
|
||||||
|
tint = 0,
|
||||||
|
title = getString(state.titleResId),
|
||||||
|
icon = 0,
|
||||||
|
isCheckable = true,
|
||||||
|
isChecked = state in value.selectedItems,
|
||||||
|
data = state,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
b.chipsState.setChips(chips)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireFilter() = (requireActivity() as FilterOwner).filter
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val TAG = "FilterSheet"
|
||||||
|
|
||||||
|
fun show(fm: FragmentManager) = FilterSheetFragment().showDistinct(fm, TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package org.koitharu.kotatsu.filter.ui.tags
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseListAdapter
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.setChecked
|
||||||
|
import org.koitharu.kotatsu.databinding.ItemCheckableNewBinding
|
||||||
|
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
|
||||||
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.errorFooterAD
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
|
||||||
|
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
|
class TagsCatalogAdapter(
|
||||||
|
listener: OnListItemClickListener<TagCatalogItem>,
|
||||||
|
) : BaseListAdapter<ListModel>(), FastScroller.SectionIndexer {
|
||||||
|
|
||||||
|
init {
|
||||||
|
addDelegate(ListItemType.FILTER_TAG, tagCatalogDelegate(listener))
|
||||||
|
addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
|
||||||
|
addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD())
|
||||||
|
addDelegate(ListItemType.FOOTER_ERROR, errorFooterAD(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSectionText(context: Context, position: Int): CharSequence? {
|
||||||
|
return (items.getOrNull(position) as? TagCatalogItem)?.tag?.title?.firstOrNull()?.uppercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tagCatalogDelegate(
|
||||||
|
listener: OnListItemClickListener<TagCatalogItem>,
|
||||||
|
) = adapterDelegateViewBinding<TagCatalogItem, ListModel, ItemCheckableNewBinding>(
|
||||||
|
{ layoutInflater, parent -> ItemCheckableNewBinding.inflate(layoutInflater, parent, false) },
|
||||||
|
) {
|
||||||
|
|
||||||
|
itemView.setOnClickListener {
|
||||||
|
listener.onItemClick(item, itemView)
|
||||||
|
}
|
||||||
|
|
||||||
|
bind { payloads ->
|
||||||
|
binding.root.text = item.tag.title
|
||||||
|
binding.root.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package org.koitharu.kotatsu.filter.ui.tags
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import dagger.hilt.android.lifecycle.withCreationCallback
|
||||||
|
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
|
||||||
|
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior
|
||||||
|
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
|
||||||
|
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.observe
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.showDistinct
|
||||||
|
import org.koitharu.kotatsu.databinding.SheetTagsBinding
|
||||||
|
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||||
|
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class TagsCatalogSheet : BaseAdaptiveSheet<SheetTagsBinding>(), OnListItemClickListener<TagCatalogItem>, TextWatcher,
|
||||||
|
AdaptiveSheetCallback, View.OnFocusChangeListener, TextView.OnEditorActionListener {
|
||||||
|
|
||||||
|
private val viewModel by viewModels<TagsCatalogViewModel>(
|
||||||
|
extrasProducer = {
|
||||||
|
defaultViewModelCreationExtras.withCreationCallback<TagsCatalogViewModel.Factory> { factory ->
|
||||||
|
factory.create((requireActivity() as FilterOwner).filter)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetTagsBinding {
|
||||||
|
return SheetTagsBinding.inflate(inflater, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewBindingCreated(binding: SheetTagsBinding, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewBindingCreated(binding, savedInstanceState)
|
||||||
|
val adapter = TagsCatalogAdapter(this)
|
||||||
|
binding.recyclerView.adapter = adapter
|
||||||
|
binding.recyclerView.setHasFixedSize(true)
|
||||||
|
binding.editSearch.setText(viewModel.searchQuery.value)
|
||||||
|
binding.editSearch.addTextChangedListener(this)
|
||||||
|
binding.editSearch.onFocusChangeListener = this
|
||||||
|
binding.editSearch.setOnEditorActionListener(this)
|
||||||
|
viewModel.content.observe(viewLifecycleOwner, adapter)
|
||||||
|
addSheetCallback(this)
|
||||||
|
disableFitToContents()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onItemClick(item: TagCatalogItem, view: View) {
|
||||||
|
val filter = (requireActivity() as FilterOwner).filter
|
||||||
|
filter.setTag(item.tag, !item.isChecked)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFocusChange(v: View?, hasFocus: Boolean) {
|
||||||
|
setExpanded(
|
||||||
|
isExpanded = hasFocus || isExpanded,
|
||||||
|
isLocked = hasFocus,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEditorAction(v: TextView, actionId: Int, event: KeyEvent?): Boolean {
|
||||||
|
return if (actionId == EditorInfo.IME_ACTION_SEARCH) {
|
||||||
|
v.clearFocus()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||||
|
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||||
|
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
val q = s?.toString().orEmpty()
|
||||||
|
viewModel.searchQuery.value = q
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStateChanged(sheet: View, newState: Int) {
|
||||||
|
viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val TAG = "TagsCatalogSheet"
|
||||||
|
|
||||||
|
fun show(fm: FragmentManager) = TagsCatalogSheet().showDistinct(fm, TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package org.koitharu.kotatsu.filter.ui.tags
|
||||||
|
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.plus
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaRepository
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||||
|
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.LoadingState
|
||||||
|
|
||||||
|
@HiltViewModel(assistedFactory = TagsCatalogViewModel.Factory::class)
|
||||||
|
class TagsCatalogViewModel @AssistedInject constructor(
|
||||||
|
@Assisted filter: MangaFilter,
|
||||||
|
mangaRepositoryFactory: MangaRepository.Factory,
|
||||||
|
dataRepository: MangaDataRepository,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val searchQuery = MutableStateFlow("")
|
||||||
|
|
||||||
|
private val tags = combine(
|
||||||
|
filter.allTags,
|
||||||
|
filter.filterTags.map { it.selectedItems },
|
||||||
|
) { all, selected ->
|
||||||
|
all.map { x ->
|
||||||
|
if (x is TagCatalogItem) {
|
||||||
|
val checked = x.tag in selected
|
||||||
|
if (x.isChecked == checked) {
|
||||||
|
x
|
||||||
|
} else {
|
||||||
|
x.copy(isChecked = checked)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, filter.allTags.value)
|
||||||
|
|
||||||
|
val content = combine(tags, searchQuery) { raw, query ->
|
||||||
|
raw.filter { x ->
|
||||||
|
x !is TagCatalogItem || x.tag.title.contains(query, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState))
|
||||||
|
|
||||||
|
@AssistedFactory
|
||||||
|
interface Factory {
|
||||||
|
fun create(filter: MangaFilter): TagsCatalogViewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.filter
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
import org.koitharu.kotatsu.core.db.entity.toEntities
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.toEntity
|
import org.koitharu.kotatsu.core.db.entity.toEntity
|
||||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||||
import org.koitharu.kotatsu.core.db.entity.toMangaTag
|
import org.koitharu.kotatsu.core.db.entity.toMangaTag
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class HistoryListViewModel @Inject constructor(
|
class HistoryListViewModel @Inject constructor(
|
||||||
private val repository: HistoryRepository,
|
private val repository: HistoryRepository,
|
||||||
private val settings: AppSettings,
|
settings: AppSettings,
|
||||||
private val extraProvider: ListExtraProvider,
|
private val extraProvider: ListExtraProvider,
|
||||||
private val localMangaRepository: LocalMangaRepository,
|
private val localMangaRepository: LocalMangaRepository,
|
||||||
networkState: NetworkState,
|
networkState: NetworkState,
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import org.koitharu.kotatsu.list.ui.model.ErrorFooter
|
|||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
fun errorFooterAD(
|
fun errorFooterAD(
|
||||||
listener: MangaListListener,
|
listener: MangaListListener?,
|
||||||
) = adapterDelegateViewBinding<ErrorFooter, ListModel, ItemErrorFooterBinding>(
|
) = adapterDelegateViewBinding<ErrorFooter, ListModel, ItemErrorFooterBinding>(
|
||||||
{ inflater, parent -> ItemErrorFooterBinding.inflate(inflater, parent, false) },
|
{ inflater, parent -> ItemErrorFooterBinding.inflate(inflater, parent, false) },
|
||||||
) {
|
) {
|
||||||
|
|
||||||
binding.root.setOnClickListener {
|
if (listener != null) {
|
||||||
listener.onRetryClick(item.exception)
|
binding.root.setOnClickListener {
|
||||||
|
listener.onRetryClick(item.exception)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bind {
|
bind {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ enum class ListItemType {
|
|||||||
FILTER_TAG,
|
FILTER_TAG,
|
||||||
FILTER_TAG_MULTI,
|
FILTER_TAG_MULTI,
|
||||||
FILTER_STATE,
|
FILTER_STATE,
|
||||||
|
FILTER_LANGUAGE,
|
||||||
HEADER,
|
HEADER,
|
||||||
MANGA_LIST,
|
MANGA_LIST,
|
||||||
MANGA_LIST_DETAILED,
|
MANGA_LIST_DETAILED,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class TypedListSpacingDecoration(
|
|||||||
ListItemType.FILTER_TAG,
|
ListItemType.FILTER_TAG,
|
||||||
ListItemType.FILTER_TAG_MULTI,
|
ListItemType.FILTER_TAG_MULTI,
|
||||||
ListItemType.FILTER_STATE,
|
ListItemType.FILTER_STATE,
|
||||||
|
ListItemType.FILTER_LANGUAGE,
|
||||||
-> outRect.set(0)
|
-> outRect.set(0)
|
||||||
|
|
||||||
ListItemType.HEADER,
|
ListItemType.HEADER,
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import org.koitharu.kotatsu.core.util.ext.textAndVisible
|
|||||||
import org.koitharu.kotatsu.databinding.FragmentPreviewBinding
|
import org.koitharu.kotatsu.databinding.FragmentPreviewBinding
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||||
import org.koitharu.kotatsu.filter.ui.model.FilterItem
|
|
||||||
import org.koitharu.kotatsu.image.ui.ImageActivity
|
import org.koitharu.kotatsu.image.ui.ImageActivity
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||||
@@ -98,8 +97,7 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
|
|||||||
if (filter == null) {
|
if (filter == null) {
|
||||||
startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag)))
|
startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag)))
|
||||||
} else {
|
} else {
|
||||||
val filterItem = FilterItem.Tag(tag, filter.header.value.allowMultipleTags, false)
|
filter.setTag(tag, true)
|
||||||
filter.onTagItemClick(filterItem, isFromChip = false)
|
|
||||||
closeSelf()
|
closeSelf()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
|
|||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.EnumSet
|
import java.util.EnumSet
|
||||||
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -160,6 +161,8 @@ class LocalMangaRepository @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun getTags() = emptySet<MangaTag>()
|
override suspend fun getTags() = emptySet<MangaTag>()
|
||||||
|
|
||||||
|
override suspend fun getLocales() = emptySet<Locale>()
|
||||||
|
|
||||||
override suspend fun getRelated(seed: Manga): List<Manga> = emptyList()
|
override suspend fun getRelated(seed: Manga): List<Manga> = emptyList()
|
||||||
|
|
||||||
suspend fun getOutputDir(manga: Manga): File? {
|
suspend fun getOutputDir(manga: Manga): File? {
|
||||||
|
|||||||
@@ -2,13 +2,8 @@ package org.koitharu.kotatsu.local.data
|
|||||||
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileFilter
|
import java.io.FileFilter
|
||||||
import java.io.FilenameFilter
|
|
||||||
|
|
||||||
class TempFileFilter : FilenameFilter, FileFilter {
|
class TempFileFilter : FileFilter {
|
||||||
|
|
||||||
override fun accept(dir: File, name: String): Boolean {
|
|
||||||
return name.endsWith(".tmp", ignoreCase = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun accept(file: File): Boolean {
|
override fun accept(file: File): Boolean {
|
||||||
return file.name.endsWith(".tmp", ignoreCase = true)
|
return file.name.endsWith(".tmp", ignoreCase = true)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.local.data.output
|
|||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import org.koitharu.kotatsu.core.model.findById
|
import org.koitharu.kotatsu.core.model.findById
|
||||||
import org.koitharu.kotatsu.core.model.isLocal
|
import org.koitharu.kotatsu.core.model.isLocal
|
||||||
import org.koitharu.kotatsu.core.util.ext.deleteAwait
|
import org.koitharu.kotatsu.core.util.ext.deleteAwait
|
||||||
@@ -20,6 +22,7 @@ class LocalMangaDirOutput(
|
|||||||
|
|
||||||
private val chaptersOutput = HashMap<MangaChapter, ZipOutput>()
|
private val chaptersOutput = HashMap<MangaChapter, ZipOutput>()
|
||||||
private val index = MangaIndex(File(rootFile, ENTRY_NAME_INDEX).takeIfReadable()?.readText())
|
private val index = MangaIndex(File(rootFile, ENTRY_NAME_INDEX).takeIfReadable()?.readText())
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (!manga.isLocal) {
|
if (!manga.isLocal) {
|
||||||
@@ -29,7 +32,7 @@ class LocalMangaDirOutput(
|
|||||||
|
|
||||||
override suspend fun mergeWithExisting() = Unit
|
override suspend fun mergeWithExisting() = Unit
|
||||||
|
|
||||||
override suspend fun addCover(file: File, ext: String) {
|
override suspend fun addCover(file: File, ext: String) = mutex.withLock {
|
||||||
val name = buildString {
|
val name = buildString {
|
||||||
append("cover")
|
append("cover")
|
||||||
if (ext.isNotEmpty() && ext.length <= 4) {
|
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||||
@@ -44,7 +47,7 @@ class LocalMangaDirOutput(
|
|||||||
flushIndex()
|
flushIndex()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
|
override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) = mutex.withLock {
|
||||||
val output = chaptersOutput.getOrPut(chapter) {
|
val output = chaptersOutput.getOrPut(chapter) {
|
||||||
ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP))
|
ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP))
|
||||||
}
|
}
|
||||||
@@ -61,14 +64,14 @@ class LocalMangaDirOutput(
|
|||||||
index.addChapter(chapter, chapterFileName(chapter))
|
index.addChapter(chapter, chapterFileName(chapter))
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun flushChapter(chapter: MangaChapter): Boolean {
|
override suspend fun flushChapter(chapter: MangaChapter): Boolean = mutex.withLock {
|
||||||
val output = chaptersOutput.remove(chapter) ?: return false
|
val output = chaptersOutput.remove(chapter) ?: return@withLock false
|
||||||
output.flushAndFinish()
|
output.flushAndFinish()
|
||||||
flushIndex()
|
flushIndex()
|
||||||
return true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun finish() {
|
override suspend fun finish() = mutex.withLock {
|
||||||
flushIndex()
|
flushIndex()
|
||||||
for (output in chaptersOutput.values) {
|
for (output in chaptersOutput.values) {
|
||||||
output.flushAndFinish()
|
output.flushAndFinish()
|
||||||
@@ -76,7 +79,7 @@ class LocalMangaDirOutput(
|
|||||||
chaptersOutput.clear()
|
chaptersOutput.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun cleanup() {
|
override suspend fun cleanup() = mutex.withLock {
|
||||||
for (output in chaptersOutput.values) {
|
for (output in chaptersOutput.values) {
|
||||||
output.file.deleteAwait()
|
output.file.deleteAwait()
|
||||||
}
|
}
|
||||||
@@ -88,7 +91,7 @@ class LocalMangaDirOutput(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun deleteChapter(chapterId: Long) {
|
suspend fun deleteChapter(chapterId: Long) = mutex.withLock {
|
||||||
val chapter = checkNotNull(index.getMangaInfo()?.chapters) {
|
val chapter = checkNotNull(index.getMangaInfo()?.chapters) {
|
||||||
"No chapters found"
|
"No chapters found"
|
||||||
}.findById(chapterId) ?: error("Chapter not found")
|
}.findById(chapterId) ?: error("Chapter not found")
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package org.koitharu.kotatsu.local.data.output
|
|||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import org.koitharu.kotatsu.core.model.isLocal
|
import org.koitharu.kotatsu.core.model.isLocal
|
||||||
import org.koitharu.kotatsu.core.util.ext.deleteAwait
|
import org.koitharu.kotatsu.core.util.ext.deleteAwait
|
||||||
import org.koitharu.kotatsu.core.util.ext.readText
|
import org.koitharu.kotatsu.core.util.ext.readText
|
||||||
@@ -20,6 +22,7 @@ class LocalMangaZipOutput(
|
|||||||
|
|
||||||
private val output = ZipOutput(File(rootFile.path + ".tmp"))
|
private val output = ZipOutput(File(rootFile.path + ".tmp"))
|
||||||
private val index = MangaIndex(null)
|
private val index = MangaIndex(null)
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (!manga.isLocal) {
|
if (!manga.isLocal) {
|
||||||
@@ -27,7 +30,7 @@ class LocalMangaZipOutput(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun mergeWithExisting() {
|
override suspend fun mergeWithExisting() = mutex.withLock {
|
||||||
if (rootFile.exists()) {
|
if (rootFile.exists()) {
|
||||||
runInterruptible(Dispatchers.IO) {
|
runInterruptible(Dispatchers.IO) {
|
||||||
mergeWith(rootFile)
|
mergeWith(rootFile)
|
||||||
@@ -35,7 +38,7 @@ class LocalMangaZipOutput(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun addCover(file: File, ext: String) {
|
override suspend fun addCover(file: File, ext: String) = mutex.withLock {
|
||||||
val name = buildString {
|
val name = buildString {
|
||||||
append(FILENAME_PATTERN.format(0, 0, 0))
|
append(FILENAME_PATTERN.format(0, 0, 0))
|
||||||
if (ext.isNotEmpty() && ext.length <= 4) {
|
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||||
@@ -49,7 +52,7 @@ class LocalMangaZipOutput(
|
|||||||
index.setCoverEntry(name)
|
index.setCoverEntry(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
|
override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) = mutex.withLock {
|
||||||
val name = buildString {
|
val name = buildString {
|
||||||
append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber))
|
append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber))
|
||||||
if (ext.isNotEmpty() && ext.length <= 4) {
|
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||||
@@ -65,7 +68,7 @@ class LocalMangaZipOutput(
|
|||||||
|
|
||||||
override suspend fun flushChapter(chapter: MangaChapter): Boolean = false
|
override suspend fun flushChapter(chapter: MangaChapter): Boolean = false
|
||||||
|
|
||||||
override suspend fun finish() {
|
override suspend fun finish() = mutex.withLock {
|
||||||
runInterruptible(Dispatchers.IO) {
|
runInterruptible(Dispatchers.IO) {
|
||||||
output.put(ENTRY_NAME_INDEX, index.toString())
|
output.put(ENTRY_NAME_INDEX, index.toString())
|
||||||
output.finish()
|
output.finish()
|
||||||
@@ -73,10 +76,12 @@ class LocalMangaZipOutput(
|
|||||||
}
|
}
|
||||||
rootFile.deleteAwait()
|
rootFile.deleteAwait()
|
||||||
output.file.renameTo(rootFile)
|
output.file.renameTo(rootFile)
|
||||||
|
Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun cleanup() {
|
override suspend fun cleanup() = mutex.withLock {
|
||||||
output.file.deleteAwait()
|
output.file.deleteAwait()
|
||||||
|
Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent
|
|||||||
import org.koitharu.kotatsu.core.util.ext.withArgs
|
import org.koitharu.kotatsu.core.util.ext.withArgs
|
||||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||||
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||||
import org.koitharu.kotatsu.filter.ui.FilterSheetFragment
|
|
||||||
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||||
|
import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
|
||||||
@@ -94,7 +94,7 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
|
|||||||
Snackbar.make(
|
Snackbar.make(
|
||||||
requireViewBinding().recyclerView,
|
requireViewBinding().recyclerView,
|
||||||
R.string.removal_completed,
|
R.string.removal_completed,
|
||||||
Snackbar.LENGTH_SHORT
|
Snackbar.LENGTH_SHORT,
|
||||||
).show()
|
).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ class LocalListFragment : MangaListFragment(), FilterOwner {
|
|||||||
fun newInstance() = LocalListFragment().withArgs(1) {
|
fun newInstance() = LocalListFragment().withArgs(1) {
|
||||||
putSerializable(
|
putSerializable(
|
||||||
RemoteListFragment.ARG_SOURCE,
|
RemoteListFragment.ARG_SOURCE,
|
||||||
MangaSource.LOCAL
|
MangaSource.LOCAL,
|
||||||
) // required by FilterCoordinator
|
) // required by FilterCoordinator
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.work.WorkerParameters
|
|||||||
import androidx.work.await
|
import androidx.work.await
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
|
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||||
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
import org.koitharu.kotatsu.local.data.LocalMangaRepository
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@@ -20,10 +21,12 @@ class LocalStorageCleanupWorker @AssistedInject constructor(
|
|||||||
@Assisted appContext: Context,
|
@Assisted appContext: Context,
|
||||||
@Assisted params: WorkerParameters,
|
@Assisted params: WorkerParameters,
|
||||||
private val localMangaRepository: LocalMangaRepository,
|
private val localMangaRepository: LocalMangaRepository,
|
||||||
|
private val dataRepository: MangaDataRepository,
|
||||||
) : CoroutineWorker(appContext, params) {
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
return if (localMangaRepository.cleanup()) {
|
return if (localMangaRepository.cleanup()) {
|
||||||
|
dataRepository.cleanupLocalManga()
|
||||||
Result.success()
|
Result.success()
|
||||||
} else {
|
} else {
|
||||||
Result.retry()
|
Result.retry()
|
||||||
|
|||||||
@@ -7,13 +7,17 @@ data class ReaderColorFilter(
|
|||||||
val brightness: Float,
|
val brightness: Float,
|
||||||
val contrast: Float,
|
val contrast: Float,
|
||||||
val isInverted: Boolean,
|
val isInverted: Boolean,
|
||||||
|
val isGrayscale: Boolean,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val isEmpty: Boolean
|
val isEmpty: Boolean
|
||||||
get() = !isInverted && brightness == 0f && contrast == 0f
|
get() = !isGrayscale && !isInverted && brightness == 0f && contrast == 0f
|
||||||
|
|
||||||
fun toColorFilter(): ColorMatrixColorFilter {
|
fun toColorFilter(): ColorMatrixColorFilter {
|
||||||
val cm = ColorMatrix()
|
val cm = ColorMatrix()
|
||||||
|
if (isGrayscale) {
|
||||||
|
cm.grayscale()
|
||||||
|
}
|
||||||
if (isInverted) {
|
if (isInverted) {
|
||||||
cm.inverted()
|
cm.inverted()
|
||||||
}
|
}
|
||||||
@@ -49,6 +53,20 @@ data class ReaderColorFilter(
|
|||||||
0.0f, 0.0f, -1.0f, 1.0f, 1.0f,
|
0.0f, 0.0f, -1.0f, 1.0f, 1.0f,
|
||||||
0.0f, 0.0f, 0.0f, 1.0f, 0.0f,
|
0.0f, 0.0f, 0.0f, 1.0f, 0.0f,
|
||||||
)
|
)
|
||||||
set(matrix)
|
postConcat(ColorMatrix(matrix))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ColorMatrix.grayscale() {
|
||||||
|
setSaturation(0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
val EMPTY = ReaderColorFilter(
|
||||||
|
brightness = 0.0f,
|
||||||
|
contrast = 0.0f,
|
||||||
|
isInverted = false,
|
||||||
|
isGrayscale = false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import coil.ImageLoader
|
|||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import coil.size.Scale
|
import coil.size.Scale
|
||||||
import coil.size.ViewSizeResolver
|
import coil.size.ViewSizeResolver
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import com.google.android.material.slider.LabelFormatter
|
import com.google.android.material.slider.LabelFormatter
|
||||||
import com.google.android.material.slider.Slider
|
import com.google.android.material.slider.Slider
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
@@ -62,6 +63,7 @@ class ColorFilterConfigActivity :
|
|||||||
viewBinding.sliderContrast.setLabelFormatter(formatter)
|
viewBinding.sliderContrast.setLabelFormatter(formatter)
|
||||||
viewBinding.sliderBrightness.setLabelFormatter(formatter)
|
viewBinding.sliderBrightness.setLabelFormatter(formatter)
|
||||||
viewBinding.switchInvert.setOnCheckedChangeListener(this)
|
viewBinding.switchInvert.setOnCheckedChangeListener(this)
|
||||||
|
viewBinding.switchGrayscale.setOnCheckedChangeListener(this)
|
||||||
viewBinding.buttonDone.setOnClickListener(this)
|
viewBinding.buttonDone.setOnClickListener(this)
|
||||||
viewBinding.buttonReset.setOnClickListener(this)
|
viewBinding.buttonReset.setOnClickListener(this)
|
||||||
|
|
||||||
@@ -84,13 +86,16 @@ class ColorFilterConfigActivity :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) {
|
override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) {
|
||||||
viewModel.setInversion(isChecked)
|
when (buttonView.id) {
|
||||||
|
R.id.switch_invert -> viewModel.setInversion(isChecked)
|
||||||
|
R.id.switch_grayscale -> viewModel.setGrayscale(isChecked)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onClick(v: View) {
|
override fun onClick(v: View) {
|
||||||
when (v.id) {
|
when (v.id) {
|
||||||
R.id.button_done -> viewModel.save()
|
R.id.button_done -> showSaveConfirmation()
|
||||||
R.id.button_reset -> viewModel.reset()
|
R.id.button_reset -> viewModel.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,10 +113,23 @@ class ColorFilterConfigActivity :
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun showSaveConfirmation() {
|
||||||
|
MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle(R.string.apply)
|
||||||
|
.setMessage(R.string.color_correction_apply_text)
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.setPositiveButton(R.string.this_manga) { _, _ ->
|
||||||
|
viewModel.saveGlobally()
|
||||||
|
}.setNeutralButton(R.string.globally) { _, _ ->
|
||||||
|
viewModel.save()
|
||||||
|
}.show()
|
||||||
|
}
|
||||||
|
|
||||||
private fun onColorFilterChanged(readerColorFilter: ReaderColorFilter?) {
|
private fun onColorFilterChanged(readerColorFilter: ReaderColorFilter?) {
|
||||||
viewBinding.sliderBrightness.setValueRounded(readerColorFilter?.brightness ?: 0f)
|
viewBinding.sliderBrightness.setValueRounded(readerColorFilter?.brightness ?: 0f)
|
||||||
viewBinding.sliderContrast.setValueRounded(readerColorFilter?.contrast ?: 0f)
|
viewBinding.sliderContrast.setValueRounded(readerColorFilter?.contrast ?: 0f)
|
||||||
viewBinding.switchInvert.setChecked(readerColorFilter?.isInverted ?: false, false)
|
viewBinding.switchInvert.setChecked(readerColorFilter?.isInverted ?: false, false)
|
||||||
|
viewBinding.switchGrayscale.setChecked(readerColorFilter?.isGrayscale ?: false, false)
|
||||||
viewBinding.imageViewAfter.colorFilter = readerColorFilter?.toColorFilter()
|
viewBinding.imageViewAfter.colorFilter = readerColorFilter?.toColorFilter()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +151,8 @@ class ColorFilterConfigActivity :
|
|||||||
private fun onLoadingChanged(isLoading: Boolean) {
|
private fun onLoadingChanged(isLoading: Boolean) {
|
||||||
viewBinding.sliderContrast.isEnabled = !isLoading
|
viewBinding.sliderContrast.isEnabled = !isLoading
|
||||||
viewBinding.sliderBrightness.isEnabled = !isLoading
|
viewBinding.sliderBrightness.isEnabled = !isLoading
|
||||||
|
viewBinding.switchInvert.isEnabled = !isLoading
|
||||||
|
viewBinding.switchGrayscale.isEnabled = !isLoading
|
||||||
viewBinding.buttonDone.isEnabled = !isLoading
|
viewBinding.buttonDone.isEnabled = !isLoading
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.koitharu.kotatsu.reader.ui.colorfilter
|
package org.koitharu.kotatsu.reader.ui.colorfilter
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.DialogInterface
|
import android.content.DialogInterface
|
||||||
import androidx.activity.OnBackPressedCallback
|
import androidx.activity.OnBackPressedCallback
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
@@ -8,7 +7,7 @@ import org.koitharu.kotatsu.R
|
|||||||
import org.koitharu.kotatsu.core.util.ext.call
|
import org.koitharu.kotatsu.core.util.ext.call
|
||||||
|
|
||||||
class ColorFilterConfigBackPressedDispatcher(
|
class ColorFilterConfigBackPressedDispatcher(
|
||||||
private val context: Context,
|
private val activity: ColorFilterConfigActivity,
|
||||||
private val viewModel: ColorFilterConfigViewModel,
|
private val viewModel: ColorFilterConfigViewModel,
|
||||||
) : OnBackPressedCallback(true), DialogInterface.OnClickListener {
|
) : OnBackPressedCallback(true), DialogInterface.OnClickListener {
|
||||||
|
|
||||||
@@ -24,12 +23,12 @@ class ColorFilterConfigBackPressedDispatcher(
|
|||||||
when (which) {
|
when (which) {
|
||||||
DialogInterface.BUTTON_NEGATIVE -> viewModel.onDismiss.call(Unit)
|
DialogInterface.BUTTON_NEGATIVE -> viewModel.onDismiss.call(Unit)
|
||||||
DialogInterface.BUTTON_NEUTRAL -> dialog.dismiss()
|
DialogInterface.BUTTON_NEUTRAL -> dialog.dismiss()
|
||||||
DialogInterface.BUTTON_POSITIVE -> viewModel.save()
|
DialogInterface.BUTTON_POSITIVE -> activity.showSaveConfirmation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showConfirmation() {
|
private fun showConfirmation() {
|
||||||
MaterialAlertDialogBuilder(context)
|
MaterialAlertDialogBuilder(activity)
|
||||||
.setTitle(R.string.color_correction)
|
.setTitle(R.string.color_correction)
|
||||||
.setMessage(R.string.text_unsaved_changes_prompt)
|
.setMessage(R.string.text_unsaved_changes_prompt)
|
||||||
.setNegativeButton(R.string.discard, this)
|
.setNegativeButton(R.string.discard, this)
|
||||||
|
|||||||
@@ -38,39 +38,25 @@ class ColorFilterConfigViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
launchLoadingJob {
|
launchLoadingJob {
|
||||||
initialColorFilter = mangaDataRepository.getColorFilter(manga.id)
|
initialColorFilter = mangaDataRepository.getColorFilter(manga.id) ?: settings.readerColorFilter
|
||||||
colorFilter.value = initialColorFilter
|
colorFilter.value = initialColorFilter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setBrightness(brightness: Float) {
|
fun setBrightness(brightness: Float) {
|
||||||
val cf = colorFilter.value
|
updateColorFilter { it.copy(brightness = brightness) }
|
||||||
colorFilter.value = ReaderColorFilter(
|
|
||||||
brightness = brightness,
|
|
||||||
contrast = cf?.contrast ?: 0f,
|
|
||||||
isInverted = cf?.isInverted ?: false,
|
|
||||||
).takeUnless { it.isEmpty }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setContrast(contrast: Float) {
|
fun setContrast(contrast: Float) {
|
||||||
val cf = colorFilter.value
|
updateColorFilter { it.copy(contrast = contrast) }
|
||||||
colorFilter.value = ReaderColorFilter(
|
|
||||||
brightness = cf?.brightness ?: 0f,
|
|
||||||
contrast = contrast,
|
|
||||||
isInverted = cf?.isInverted ?: false,
|
|
||||||
).takeUnless { it.isEmpty }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setInversion(invert: Boolean) {
|
fun setInversion(invert: Boolean) {
|
||||||
val cf = colorFilter.value
|
updateColorFilter { it.copy(isInverted = invert) }
|
||||||
if (invert == cf?.isInverted) {
|
}
|
||||||
return
|
|
||||||
}
|
fun setGrayscale(grayscale: Boolean) {
|
||||||
colorFilter.value = ReaderColorFilter(
|
updateColorFilter { it.copy(isGrayscale = grayscale) }
|
||||||
brightness = cf?.brightness ?: 0f,
|
|
||||||
contrast = cf?.contrast ?: 0f,
|
|
||||||
isInverted = invert,
|
|
||||||
).takeUnless { it.isEmpty }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reset() {
|
fun reset() {
|
||||||
@@ -83,4 +69,20 @@ class ColorFilterConfigViewModel @Inject constructor(
|
|||||||
onDismiss.call(Unit)
|
onDismiss.call(Unit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun saveGlobally() {
|
||||||
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
|
settings.readerColorFilter = colorFilter.value
|
||||||
|
if (mangaDataRepository.getColorFilter(manga.id) != null) {
|
||||||
|
mangaDataRepository.saveColorFilter(manga, colorFilter.value)
|
||||||
|
}
|
||||||
|
onDismiss.call(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun updateColorFilter(block: (ReaderColorFilter) -> ReaderColorFilter) {
|
||||||
|
colorFilter.value = block(
|
||||||
|
colorFilter.value ?: ReaderColorFilter.EMPTY,
|
||||||
|
).takeUnless { it.isEmpty }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class ReaderSettings(
|
|||||||
get() = settings.zoomMode
|
get() = settings.zoomMode
|
||||||
|
|
||||||
val colorFilter: ReaderColorFilter?
|
val colorFilter: ReaderColorFilter?
|
||||||
get() = colorFilterFlow.value?.takeUnless { it.isEmpty }
|
get() = colorFilterFlow.value?.takeUnless { it.isEmpty } ?: settings.readerColorFilter
|
||||||
|
|
||||||
val isReaderOptimizationEnabled: Boolean
|
val isReaderOptimizationEnabled: Boolean
|
||||||
get() = settings.isReaderOptimizationEnabled
|
get() = settings.isReaderOptimizationEnabled
|
||||||
@@ -96,6 +96,18 @@ class ReaderSettings(
|
|||||||
FlowCollector<ReaderColorFilter?>,
|
FlowCollector<ReaderColorFilter?>,
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
|
|
||||||
|
private val settingsKeys = setOf(
|
||||||
|
AppSettings.KEY_ZOOM_MODE,
|
||||||
|
AppSettings.KEY_PAGES_NUMBERS,
|
||||||
|
AppSettings.KEY_READER_BACKGROUND,
|
||||||
|
AppSettings.KEY_32BIT_COLOR,
|
||||||
|
AppSettings.KEY_READER_OPTIMIZE,
|
||||||
|
AppSettings.KEY_CF_CONTRAST,
|
||||||
|
AppSettings.KEY_CF_BRIGHTNESS,
|
||||||
|
AppSettings.KEY_CF_INVERTED,
|
||||||
|
AppSettings.KEY_CF_GRAYSCALE,
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun emit(value: ReaderColorFilter?) {
|
override suspend fun emit(value: ReaderColorFilter?) {
|
||||||
withContext(Dispatchers.Main.immediate) {
|
withContext(Dispatchers.Main.immediate) {
|
||||||
notifyChanged()
|
notifyChanged()
|
||||||
@@ -103,13 +115,7 @@ class ReaderSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||||
if (
|
if (key in settingsKeys) {
|
||||||
key == AppSettings.KEY_ZOOM_MODE ||
|
|
||||||
key == AppSettings.KEY_PAGES_NUMBERS ||
|
|
||||||
key == AppSettings.KEY_READER_BACKGROUND ||
|
|
||||||
key == AppSettings.KEY_32BIT_COLOR ||
|
|
||||||
key == AppSettings.KEY_READER_OPTIMIZE
|
|
||||||
) {
|
|
||||||
notifyChanged()
|
notifyChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import androidx.annotation.CallSuper
|
import androidx.annotation.CallSuper
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.viewbinding.ViewBinding
|
import androidx.viewbinding.ViewBinding
|
||||||
|
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||||
import org.koitharu.kotatsu.core.os.NetworkState
|
import org.koitharu.kotatsu.core.os.NetworkState
|
||||||
import org.koitharu.kotatsu.core.ui.list.lifecycle.LifecycleAwareViewHolder
|
import org.koitharu.kotatsu.core.ui.list.lifecycle.LifecycleAwareViewHolder
|
||||||
@@ -69,9 +70,11 @@ abstract class BasePageHolder<B : ViewBinding>(
|
|||||||
delegate.onRecycle()
|
delegate.onRecycle()
|
||||||
}
|
}
|
||||||
|
|
||||||
protected fun getBackgroundDownsampling() = when {
|
protected fun SubsamplingScaleImageView.applyDownsampling(isForeground: Boolean) {
|
||||||
!settings.isReaderOptimizationEnabled -> 1
|
downsampling = when {
|
||||||
context.isLowRamDevice() -> 8
|
isForeground || !settings.isReaderOptimizationEnabled -> 1
|
||||||
else -> 4
|
context.isLowRamDevice() -> 8
|
||||||
|
else -> 4
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.launchIn
|
|||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.plus
|
import kotlinx.coroutines.plus
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.yield
|
import kotlinx.coroutines.yield
|
||||||
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
|
||||||
import org.koitharu.kotatsu.core.os.NetworkState
|
import org.koitharu.kotatsu.core.os.NetworkState
|
||||||
@@ -158,7 +159,9 @@ class PageHolderDelegate(
|
|||||||
callback.onLoadingStarted()
|
callback.onLoadingStarted()
|
||||||
yield()
|
yield()
|
||||||
try {
|
try {
|
||||||
val task = loader.loadPageAsync(data, force)
|
val task = withContext(Dispatchers.Default) {
|
||||||
|
loader.loadPageAsync(data, force)
|
||||||
|
}
|
||||||
uri = coroutineScope {
|
uri = coroutineScope {
|
||||||
val progressObserver = observeProgress(this, task.progressAsFlow())
|
val progressObserver = observeProgress(this, task.progressAsFlow())
|
||||||
val file = task.await()
|
val file = task.await()
|
||||||
|
|||||||
@@ -47,12 +47,12 @@ open class PageHolder(
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
binding.ssiv.downsampling = 1
|
binding.ssiv.applyDownsampling(isForeground = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
binding.ssiv.downsampling = getBackgroundDownsampling()
|
binding.ssiv.applyDownsampling(isForeground = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onConfigChanged() {
|
override fun onConfigChanged() {
|
||||||
@@ -60,7 +60,7 @@ open class PageHolder(
|
|||||||
if (settings.applyBitmapConfig(binding.ssiv)) {
|
if (settings.applyBitmapConfig(binding.ssiv)) {
|
||||||
delegate.reload()
|
delegate.reload()
|
||||||
}
|
}
|
||||||
binding.ssiv.downsampling = if (isResumed()) 1 else getBackgroundDownsampling()
|
binding.ssiv.applyDownsampling(isResumed())
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("SetTextI18n")
|
@SuppressLint("SetTextI18n")
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.reader.ui.pager.standard
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import androidx.viewpager2.widget.ViewPager2
|
|
||||||
import org.koitharu.kotatsu.reader.ui.pager.OnBoundsScrollListener
|
|
||||||
|
|
||||||
class PagerPaginationListener(
|
|
||||||
private val adapter: RecyclerView.Adapter<*>,
|
|
||||||
private val offset: Int,
|
|
||||||
private val listener: OnBoundsScrollListener
|
|
||||||
) : ViewPager2.OnPageChangeCallback() {
|
|
||||||
|
|
||||||
private var firstItemId: Long = 0
|
|
||||||
private var lastItemId: Long = 0
|
|
||||||
|
|
||||||
override fun onPageSelected(position: Int) {
|
|
||||||
val itemCount = adapter.itemCount
|
|
||||||
if (itemCount == 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (position <= offset && adapter.getItemId(0) != firstItemId) {
|
|
||||||
firstItemId = adapter.getItemId(0)
|
|
||||||
listener.onScrolledToStart()
|
|
||||||
} else if (position >= itemCount - offset && adapter.getItemId(itemCount - 1) != lastItemId) {
|
|
||||||
lastItemId = adapter.getItemId(itemCount - 1)
|
|
||||||
listener.onScrolledToEnd()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import org.koitharu.kotatsu.reader.ui.pager.OnBoundsScrollListener
|
|
||||||
|
|
||||||
class ListPaginationListener(
|
|
||||||
private val offset: Int,
|
|
||||||
private val listener: OnBoundsScrollListener
|
|
||||||
) : RecyclerView.OnScrollListener() {
|
|
||||||
|
|
||||||
private var firstItemId: Long = 0
|
|
||||||
private var lastItemId: Long = 0
|
|
||||||
|
|
||||||
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
|
||||||
val adapter = recyclerView.adapter ?: return
|
|
||||||
val layoutManager = (recyclerView.layoutManager as? LinearLayoutManager) ?: return
|
|
||||||
val firstVisiblePosition = layoutManager.findFirstVisibleItemPosition()
|
|
||||||
val lastVisiblePosition = layoutManager.findLastVisibleItemPosition()
|
|
||||||
val itemCount = adapter.itemCount
|
|
||||||
if (itemCount == 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (lastVisiblePosition >= itemCount - offset && adapter.getItemId(itemCount - 1) != lastItemId) {
|
|
||||||
lastItemId = adapter.getItemId(itemCount - 1)
|
|
||||||
listener.onScrolledToEnd()
|
|
||||||
} else if (firstVisiblePosition <= offset && adapter.getItemId(0) != firstItemId) {
|
|
||||||
firstItemId = adapter.getItemId(0)
|
|
||||||
listener.onScrolledToStart()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -37,22 +37,22 @@ class WebtoonHolder(
|
|||||||
bindingInfo.buttonErrorDetails.setOnClickListener(this)
|
bindingInfo.buttonErrorDetails.setOnClickListener(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
binding.ssiv.applyDownsampling(isForeground = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
binding.ssiv.applyDownsampling(isForeground = false)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onConfigChanged() {
|
override fun onConfigChanged() {
|
||||||
super.onConfigChanged()
|
super.onConfigChanged()
|
||||||
if (settings.applyBitmapConfig(binding.ssiv)) {
|
if (settings.applyBitmapConfig(binding.ssiv)) {
|
||||||
delegate.reload()
|
delegate.reload()
|
||||||
}
|
}
|
||||||
// FIXME binding.ssiv.downsampling = if (isResumed()) 1 else getBackgroundDownsampling()
|
binding.ssiv.applyDownsampling(isResumed())
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
binding.ssiv.downsampling = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
super.onPause()
|
|
||||||
// FIXME binding.ssiv.downsampling = getBackgroundDownsampling()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBind(data: ReaderPage) {
|
override fun onBind(data: ReaderPage) {
|
||||||
|
|||||||
@@ -88,6 +88,18 @@ class WebtoonImageView @JvmOverloads constructor(
|
|||||||
setMeasuredDimension(width, height)
|
setMeasuredDimension(width, height)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDownsamplingChanged() {
|
||||||
|
super.onDownsamplingChanged()
|
||||||
|
adjustScale()
|
||||||
|
computeScrollRange()
|
||||||
|
ancestors.firstNotNullOfOrNull { it as? WebtoonRecyclerView }?.updateChildrenScroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReady() {
|
||||||
|
super.onReady()
|
||||||
|
adjustScale()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||||
super.onSizeChanged(w, h, oldw, oldh)
|
super.onSizeChanged(w, h, oldw, oldh)
|
||||||
if (oldh == h || oldw == 0 || oldh == 0 || scrollRange == SCROLL_UNKNOWN) return
|
if (oldh == h || oldw == 0 || oldh == 0 || scrollRange == SCROLL_UNKNOWN) return
|
||||||
@@ -100,18 +112,9 @@ class WebtoonImageView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onReady() {
|
|
||||||
super.onReady()
|
|
||||||
adjustScale()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDownsamplingChanged() {
|
|
||||||
super.onDownsamplingChanged()
|
|
||||||
adjustScale()
|
|
||||||
computeScrollRange()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scrollToInternal(pos: Int) {
|
private fun scrollToInternal(pos: Int) {
|
||||||
|
minScale = width / sWidth.toFloat()
|
||||||
|
maxScale = minScale
|
||||||
scrollPos = pos
|
scrollPos = pos
|
||||||
ct.set(sWidth / 2f, (height / 2f + pos.toFloat()) / minScale)
|
ct.set(sWidth / 2f, (height / 2f + pos.toFloat()) / minScale)
|
||||||
setScaleAndCenter(minScale, ct)
|
setScaleAndCenter(minScale, ct)
|
||||||
@@ -125,13 +128,13 @@ class WebtoonImageView @JvmOverloads constructor(
|
|||||||
scrollRange = (totalHeight - height).coerceAtLeast(0)
|
scrollRange = (totalHeight - height).coerceAtLeast(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parentHeight(): Int {
|
|
||||||
return ancestors.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun adjustScale() {
|
private fun adjustScale() {
|
||||||
minScale = width / sWidth.toFloat()
|
minScale = width / sWidth.toFloat()
|
||||||
maxScale = minScale
|
maxScale = minScale
|
||||||
minimumScaleType = SCALE_TYPE_CUSTOM
|
minimumScaleType = SCALE_TYPE_CUSTOM
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parentHeight(): Int {
|
||||||
|
return ancestors.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.core.view.ViewCompat.TYPE_TOUCH
|
import androidx.core.view.ViewCompat.TYPE_TOUCH
|
||||||
import androidx.core.view.forEach
|
import androidx.core.view.forEach
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition
|
import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition
|
||||||
import java.util.LinkedList
|
import java.util.LinkedList
|
||||||
import java.util.WeakHashMap
|
import java.util.WeakHashMap
|
||||||
@@ -104,12 +106,16 @@ class WebtoonRecyclerView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun notifyScrollChanged(dy: Int) {
|
private fun notifyScrollChanged(dy: Int) {
|
||||||
|
updateChildrenScroll()
|
||||||
val listeners = onPageScrollListeners
|
val listeners = onPageScrollListeners
|
||||||
if (listeners.isNullOrEmpty()) {
|
if (listeners.isNullOrEmpty()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val centerPosition = findCenterViewPosition()
|
val centerPosition = findCenterViewPosition()
|
||||||
listeners.forEach { it.dispatchScroll(this, dy, centerPosition) }
|
listeners.forEach { it.dispatchScroll(this, dy, centerPosition) }
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
validateLayout()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun relayoutChildren() {
|
fun relayoutChildren() {
|
||||||
@@ -121,6 +127,32 @@ class WebtoonRecyclerView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun updateChildrenScroll() {
|
||||||
|
forEach { child ->
|
||||||
|
val ssiv = (child as WebtoonFrameLayout).target
|
||||||
|
when {
|
||||||
|
child.top < 0 -> ssiv.scrollTo(ssiv.getScrollRange())
|
||||||
|
child.top > 0 -> ssiv.scrollTo(0)
|
||||||
|
else -> ssiv.scrollBy(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateLayout() {
|
||||||
|
forEach { child ->
|
||||||
|
val ssiv = (child as WebtoonFrameLayout).target
|
||||||
|
val scroll = ssiv.getScroll()
|
||||||
|
val assertion = when {
|
||||||
|
child.top < 0 -> scroll == ssiv.getScrollRange()
|
||||||
|
child.top > 0 -> scroll == 0
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
if (!assertion) {
|
||||||
|
Toast.makeText(context, "Scroll = $scroll for view with top: ${child.top}", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
abstract class OnPageScrollListener {
|
abstract class OnPageScrollListener {
|
||||||
|
|
||||||
private var lastPosition = NO_POSITION
|
private var lastPosition = NO_POSITION
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.reader.ui.thumbnails.adapter
|
|
||||||
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter
|
|
||||||
import org.koitharu.kotatsu.reader.ui.thumbnails.PageThumbnail
|
|
||||||
|
|
||||||
class TargetScrollObserver(
|
|
||||||
private val recyclerView: RecyclerView,
|
|
||||||
) : RecyclerView.AdapterDataObserver() {
|
|
||||||
|
|
||||||
private var isScrollToCurrentPending = true
|
|
||||||
|
|
||||||
private val layoutManager: LinearLayoutManager
|
|
||||||
get() = recyclerView.layoutManager as LinearLayoutManager
|
|
||||||
|
|
||||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
|
||||||
if (isScrollToCurrentPending) {
|
|
||||||
postScroll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun postScroll() {
|
|
||||||
recyclerView.post {
|
|
||||||
scrollToTarget()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scrollToTarget() {
|
|
||||||
val adapter = recyclerView.adapter ?: return
|
|
||||||
if (recyclerView.computeVerticalScrollRange() == 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val snapshot = (adapter as? AsyncListDifferDelegationAdapter<*>)?.items ?: return
|
|
||||||
val target = snapshot.indexOfFirst { it is PageThumbnail && it.isCurrent }
|
|
||||||
if (target in snapshot.indices) {
|
|
||||||
layoutManager.scrollToPositionWithOffset(target, 0)
|
|
||||||
isScrollToCurrentPending = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,8 +21,8 @@ import org.koitharu.kotatsu.core.util.ext.withArgs
|
|||||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||||
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
import org.koitharu.kotatsu.filter.ui.FilterOwner
|
||||||
import org.koitharu.kotatsu.filter.ui.FilterSheetFragment
|
|
||||||
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
import org.koitharu.kotatsu.filter.ui.MangaFilter
|
||||||
|
import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment
|
||||||
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
import org.koitharu.kotatsu.list.ui.MangaListFragment
|
||||||
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ open class RemoteListViewModel @Inject constructor(
|
|||||||
when {
|
when {
|
||||||
list.isNullOrEmpty() && error != null -> add(error.toErrorState(canRetry = true))
|
list.isNullOrEmpty() && error != null -> add(error.toErrorState(canRetry = true))
|
||||||
list == null -> add(LoadingState)
|
list == null -> add(LoadingState)
|
||||||
list.isEmpty() -> add(createEmptyState(header.value.hasSelectedTags))
|
list.isEmpty() -> add(createEmptyState(canResetFilter = header.value.isFilterApplied))
|
||||||
else -> {
|
else -> {
|
||||||
list.toUi(this, mode, listExtraProvider)
|
list.toUi(this, mode, listExtraProvider)
|
||||||
when {
|
when {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import okhttp3.Authenticator
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.Route
|
import okhttp3.Route
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
|
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ class ScrobblingSelectorViewModel @Inject constructor(
|
|||||||
if (!append) {
|
if (!append) {
|
||||||
scrobblerMangaList.value = list
|
scrobblerMangaList.value = list
|
||||||
} else if (list.isNotEmpty()) {
|
} else if (list.isNotEmpty()) {
|
||||||
scrobblerMangaList.value = scrobblerMangaList.value + list
|
scrobblerMangaList.value += list
|
||||||
}
|
}
|
||||||
hasNextPage.value = list.isNotEmpty()
|
hasNextPage.value = list.isNotEmpty()
|
||||||
}.onFailure { error ->
|
}.onFailure { error ->
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import okhttp3.Authenticator
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.Route
|
import okhttp3.Route
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
|
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import okhttp3.Authenticator
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import okhttp3.Route
|
import okhttp3.Route
|
||||||
import org.koitharu.kotatsu.BuildConfig
|
|
||||||
import org.koitharu.kotatsu.core.network.CommonHeaders
|
import org.koitharu.kotatsu.core.network.CommonHeaders
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
|
import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user