diff --git a/.gitignore b/.gitignore index 621f3e800..174302fc9 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ /.idea/kotlinc.xml /.idea/deploymentTargetDropDown.xml /.idea/androidTestResultsUserPreferences.xml +/.idea/deploymentTargetSelector.xml /.idea/render.experimental.xml /.idea/inspectionProfiles/ .DS_Store diff --git a/app/build.gradle b/app/build.gradle index 1e66f7fee..e1650e5f4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -16,8 +16,8 @@ android { applicationId 'org.koitharu.kotatsu' minSdk = 21 targetSdk = 34 - versionCode = 602 - versionName = '6.4.2' + versionCode = 608 + versionName = '6.5.2' generatedDensities = [] testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' ksp { @@ -82,7 +82,7 @@ afterEvaluate { } dependencies { //noinspection GradleDependency - implementation('com.github.KotatsuApp:kotatsu-parsers:0efd5437f9') { + implementation('com.github.KotatsuApp:kotatsu-parsers:4a0e7221b0') { exclude group: 'org.json', module: 'json' } @@ -92,10 +92,9 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.core:core-ktx:1.12.0' - implementation 'androidx.activity:activity-ktx:1.8.1' + implementation 'androidx.activity:activity-ktx:1.8.2' implementation 'androidx.fragment:fragment-ktx:1.6.2' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2' -// implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.2' implementation 'androidx.lifecycle:lifecycle-service:2.6.2' implementation 'androidx.lifecycle:lifecycle-process:2.6.2' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' @@ -104,7 +103,7 @@ dependencies { implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02' implementation 'androidx.preference:preference-ktx:1.2.1' implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha05' - implementation 'com.google.android.material:material:1.10.0' + implementation 'com.google.android.material:material:1.11.0' implementation 'androidx.lifecycle:lifecycle-common-java8:2.6.2' implementation 'androidx.work:work-runtime:2.9.0' @@ -121,19 +120,19 @@ dependencies { implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0' - implementation 'com.squareup.okio:okio:3.6.0' + implementation 'com.squareup.okio:okio:3.7.0' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl:4.3.2' implementation 'com.hannesdorfmann:adapterdelegates4-kotlin-dsl-viewbinding:4.3.2' - implementation 'com.google.dagger:hilt-android:2.48.1' - kapt 'com.google.dagger:hilt-compiler:2.48.1' + implementation 'com.google.dagger:hilt-android:2.50' + kapt 'com.google.dagger:hilt-compiler:2.50' implementation 'androidx.hilt:hilt-work:1.1.0' kapt 'androidx.hilt:hilt-compiler:1.1.0' implementation 'io.coil-kt:coil-base: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 'io.noties.markwon:core:4.6.2' @@ -156,6 +155,6 @@ dependencies { androidTestImplementation 'androidx.room:room-testing:2.6.1' androidTestImplementation 'com.squareup.moshi:moshi-kotlin:1.15.0' - androidTestImplementation 'com.google.dagger:hilt-android-testing:2.48.1' - kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.48.1' + androidTestImplementation 'com.google.dagger:hilt-android-testing:2.50' + kaptAndroidTest 'com.google.dagger:hilt-android-compiler:2.50' } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt index 65a4966ea..11a2a47c7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt @@ -61,13 +61,20 @@ class CaptchaNotifier( override fun onError(request: ImageRequest, result: ErrorResult) { super.onError(request, result) val e = result.throwable - if (e is CloudFlareProtectedException) { + if (e is CloudFlareProtectedException && request.parameters.value(PARAM_IGNORE_CAPTCHA) != true) { notify(e) } } - private companion object { + companion object { + fun ImageRequest.Builder.ignoreCaptchaErrors() = setParameter( + key = PARAM_IGNORE_CAPTCHA, + value = true, + memoryCacheKey = null, + ) + + private const val PARAM_IGNORE_CAPTCHA = "ignore_captcha" private const val CHANNEL_ID = "captcha" private const val TAG = CHANNEL_ID } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt index 4e994274a..4082d125a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt @@ -11,6 +11,7 @@ import androidx.work.WorkManager import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.acra.ACRA import org.acra.ReportField import org.acra.config.dialog @@ -39,7 +40,7 @@ open class BaseApp : Application(), Configuration.Provider { lateinit var activityLifecycleCallbacks: Set<@JvmSuppressWildcards ActivityLifecycleCallbacks> @Inject - lateinit var database: MangaDatabase + lateinit var database: Provider @Inject lateinit var settings: AppSettings @@ -51,7 +52,7 @@ open class BaseApp : Application(), Configuration.Provider { lateinit var appValidator: AppValidator @Inject - lateinit var workScheduleManager: WorkScheduleManager + lateinit var workScheduleManager: Provider @Inject lateinit var workManagerProvider: Provider @@ -63,14 +64,19 @@ open class BaseApp : Application(), Configuration.Provider { override fun onCreate() { super.onCreate() - ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.toString()) AppCompatDelegate.setDefaultNightMode(settings.theme) AppCompatDelegate.setApplicationLocales(settings.appLocales) setupActivityLifecycleCallbacks() + processLifecycleScope.launch { + val isOriginalApp = withContext(Dispatchers.Default) { + appValidator.isOriginalApp + } + ACRA.errorReporter.putCustomData("isOriginalApp", isOriginalApp.toString()) + } processLifecycleScope.launch(Dispatchers.Default) { setupDatabaseObservers() } - workScheduleManager.init() + workScheduleManager.get().init() WorkServiceStopHelper(workManagerProvider).setup() } @@ -79,13 +85,6 @@ open class BaseApp : Application(), Configuration.Provider { initAcra { buildConfigClass = BuildConfig::class.java reportFormat = StringFormat.JSON - excludeMatchingSharedPreferencesKeys = listOf( - "sources_\\w+", - AppSettings.KEY_APP_PASSWORD, - AppSettings.KEY_PROXY_LOGIN, - AppSettings.KEY_PROXY_ADDRESS, - AppSettings.KEY_PROXY_PASSWORD, - ) httpSender { uri = getString(R.string.url_error_report) basicAuthLogin = getString(R.string.acra_login) @@ -102,7 +101,6 @@ open class BaseApp : Application(), Configuration.Provider { ReportField.STACK_TRACE, ReportField.CRASH_CONFIGURATION, ReportField.CUSTOM_DATA, - ReportField.SHARED_PREFERENCES, ) dialog { @@ -117,7 +115,7 @@ open class BaseApp : Application(), Configuration.Provider { @WorkerThread private fun setupDatabaseObservers() { - val tracker = database.invalidationTracker + val tracker = database.get().invalidationTracker databaseObservers.forEach { tracker.addObserver(it) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt index 7498608c2..ae92bff4e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt @@ -3,17 +3,20 @@ package org.koitharu.kotatsu.core.backup import org.json.JSONArray class BackupEntry( - val name: String, + val name: Name, val data: JSONArray ) { - companion object Names { + enum class Name( + val key: String, + ) { - const val INDEX = "index" - const val HISTORY = "history" - const val CATEGORIES = "categories" - const val FAVOURITES = "favourites" - const val SETTINGS = "settings" - const val BOOKMARKS = "bookmarks" + INDEX("index"), + HISTORY("history"), + CATEGORIES("categories"), + FAVOURITES("favourites"), + SETTINGS("settings"), + BOOKMARKS("bookmarks"), + SOURCES("sources"), } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt index 1ced43f5f..82ed23103 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt @@ -7,8 +7,10 @@ import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.parsers.util.json.JSONIterator +import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import java.util.Date import javax.inject.Inject private const val PAGE_SIZE = 10 @@ -20,7 +22,7 @@ class BackupRepository @Inject constructor( suspend fun dumpHistory(): BackupEntry { var offset = 0 - val entry = BackupEntry(BackupEntry.HISTORY, JSONArray()) + val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray()) while (true) { val history = db.getHistoryDao().findAll(offset, PAGE_SIZE) if (history.isEmpty()) { @@ -41,7 +43,7 @@ class BackupRepository @Inject constructor( } suspend fun dumpCategories(): BackupEntry { - val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray()) + val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray()) val categories = db.getFavouriteCategoriesDao().findAll() for (item in categories) { entry.data.put(JsonSerializer(item).toJson()) @@ -51,7 +53,7 @@ class BackupRepository @Inject constructor( suspend fun dumpFavourites(): BackupEntry { var offset = 0 - val entry = BackupEntry(BackupEntry.FAVOURITES, JSONArray()) + val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray()) while (true) { val favourites = db.getFavouritesDao().findAll(offset, PAGE_SIZE) if (favourites.isEmpty()) { @@ -72,7 +74,7 @@ class BackupRepository @Inject constructor( } suspend fun dumpBookmarks(): BackupEntry { - val entry = BackupEntry(BackupEntry.BOOKMARKS, JSONArray()) + val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray()) val all = db.getBookmarksDao().findAll() for ((m, b) in all) { val json = JSONObject() @@ -90,7 +92,7 @@ class BackupRepository @Inject constructor( } fun dumpSettings(): BackupEntry { - val entry = BackupEntry(BackupEntry.SETTINGS, JSONArray()) + val entry = BackupEntry(BackupEntry.Name.SETTINGS, JSONArray()) val settingsDump = settings.getAllValues().toMutableMap() settingsDump.remove(AppSettings.KEY_APP_PASSWORD) settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD) @@ -101,8 +103,18 @@ class BackupRepository @Inject constructor( return entry } + suspend fun dumpSources(): BackupEntry { + val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray()) + val all = db.getSourcesDao().findAll() + for (source in all) { + val json = JsonSerializer(source).toJson() + entry.data.put(json) + } + return entry + } + fun createIndex(): BackupEntry { - val entry = BackupEntry(BackupEntry.INDEX, JSONArray()) + val entry = BackupEntry(BackupEntry.Name.INDEX, JSONArray()) val json = JSONObject() json.put("app_id", BuildConfig.APPLICATION_ID) json.put("app_version", BuildConfig.VERSION_CODE) @@ -111,6 +123,11 @@ class BackupRepository @Inject constructor( return entry } + fun getBackupDate(entry: BackupEntry?): Date? { + val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0 + return if (timestamp == 0L) null else Date(timestamp) + } + suspend fun restoreHistory(entry: BackupEntry): CompositeResult { val result = CompositeResult() for (item in entry.data.JSONIterator()) { @@ -184,6 +201,17 @@ class BackupRepository @Inject constructor( return result } + suspend fun restoreSources(entry: BackupEntry): CompositeResult { + val result = CompositeResult() + for (item in entry.data.JSONIterator()) { + val source = JsonDeserializer(item).toMangaSourceEntity() + result += runCatchingCancellable { + db.getSourcesDao().upsert(source) + } + } + return result + } + fun restoreSettings(entry: BackupEntry): CompositeResult { val result = CompositeResult() for (item in entry.data.JSONIterator()) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt index 5416836f9..61da0c264 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipInput.kt @@ -1,25 +1,44 @@ package org.koitharu.kotatsu.core.backup +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import okio.Closeable import org.json.JSONArray +import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import java.io.File +import java.util.EnumSet import java.util.zip.ZipFile class BackupZipInput(val file: File) : Closeable { private val zipFile = ZipFile(file) - suspend fun getEntry(name: String): BackupEntry? = runInterruptible(Dispatchers.IO) { - val entry = zipFile.getEntry(name) ?: return@runInterruptible null + suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) { + val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null val json = zipFile.getInputStream(entry).use { JSONArray(it.bufferedReader().readText()) } BackupEntry(name, json) } + suspend fun entries(): Set = runInterruptible(Dispatchers.IO) { + zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze -> + BackupEntry.Name.entries.find { it.key == ze.name } + } + } + override fun close() { zipFile.close() } + + fun cleanupAsync() { + processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) { + runCatching { + close() + file.delete() + } + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt index c246818de..07bce8ea3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt @@ -17,7 +17,7 @@ class BackupZipOutput(val file: File) : Closeable { private val output = ZipOutput(file, Deflater.BEST_COMPRESSION) suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) { - output.put(entry.name, entry.data.toString(2)) + output.put(entry.name.key, entry.data.toString(2)) } suspend fun finish() = runInterruptible(Dispatchers.IO) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt index 3a0789048..60462178e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.backup import org.json.JSONObject import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity +import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity @@ -78,6 +79,12 @@ class JsonDeserializer(private val json: JSONObject) { percent = json.getDouble("percent").toFloat(), ) + fun toMangaSourceEntity() = MangaSourceEntity( + source = json.getString("source"), + isEnabled = json.getBoolean("enabled"), + sortKey = json.getInt("sort_key"), + ) + fun toMap(): Map { val map = mutableMapOf() val keys = json.keys() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt index 34a406a3b..208f4ed32 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.backup import org.json.JSONObject import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity +import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity @@ -82,6 +83,14 @@ class JsonSerializer private constructor(private val json: JSONObject) { }, ) + constructor(e: MangaSourceEntity) : this( + JSONObject().apply { + put("source", e.source) + put("enabled", e.isEnabled) + put("sort_key", e.sortKey) + }, + ) + constructor(m: Map) : this( JSONObject(m), ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringLruCache.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringLruCache.kt index 34d46dfca..aa9465c32 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringLruCache.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringLruCache.kt @@ -12,7 +12,7 @@ class ExpiringLruCache( private val cache = LruCache>(maxSize) operator fun get(key: ContentCache.Key): T? { - val value = cache.get(key) ?: return null + val value = cache[key] ?: return null if (value.isExpired) { cache.remove(key) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt index 1a6adcea1..5565f2c58 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt @@ -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.Migration15To16 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.Migration2To3 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.TracksDao -const val DATABASE_VERSION = 17 +const val DATABASE_VERSION = 18 @Database( entities = [ @@ -108,6 +109,7 @@ fun getDatabaseMigrations(context: Context): Array = arrayOf( Migration14To15(), Migration15To16(), Migration16To17(context), + Migration17To18(), ) fun MangaDatabase(context: Context): MangaDatabase = Room diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaDao.kt index 404fa10c3..7ee5567b0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaDao.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.core.db.dao import androidx.room.Dao +import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query @@ -23,6 +24,10 @@ abstract class MangaDao { @Query("SELECT * FROM manga WHERE public_url = :publicUrl") abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags? + @Transaction + @Query("SELECT * FROM manga WHERE source = :source") + abstract suspend fun findAllBySource(source: String): List + @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") abstract suspend fun searchByTitle(query: String, limit: Int): List @@ -43,6 +48,10 @@ abstract class MangaDao { @Query("DELETE FROM manga_tags WHERE manga_id = :mangaId") abstract suspend fun clearTagRelation(mangaId: Long) + @Transaction + @Delete + abstract suspend fun delete(subjects: Collection) + @Transaction open suspend fun upsert(manga: MangaEntity, tags: Iterable? = null) { upsert(manga) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt index 98512e8b8..18fe1db04 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaPrefsEntity.kt @@ -24,4 +24,5 @@ data class MangaPrefsEntity( @ColumnInfo(name = "cf_brightness") val cfBrightness: Float, @ColumnInfo(name = "cf_contrast") val cfContrast: Float, @ColumnInfo(name = "cf_invert") val cfInvert: Boolean, + @ColumnInfo(name = "cf_grayscale") val cfGrayscale: Boolean, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration17To18.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration17To18.kt new file mode 100644 index 000000000..e80e77b97 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration17To18.kt @@ -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") + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CompositeException.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CompositeException.kt deleted file mode 100644 index e8c554107..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/CompositeException.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.koitharu.kotatsu.core.exceptions - -import org.koitharu.kotatsu.parsers.util.mapNotNullToSet - -class CompositeException(val errors: Collection) : Exception() { - - override val message: String = errors.mapNotNullToSet { it.message }.joinToString() -} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt index eea9ae360..5818fcf28 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.core.model import android.net.Uri +import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.os.LocaleListCompat import org.koitharu.kotatsu.R @@ -43,6 +44,15 @@ val MangaState.titleResId: Int 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? { return chapters?.findById(id) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt index 5ca61685b..dc736c8b9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt @@ -1,17 +1,23 @@ package org.koitharu.kotatsu.core.model import android.content.Context +import android.graphics.Color +import android.text.SpannableStringBuilder +import android.text.style.ForegroundColorSpan +import android.text.style.RelativeSizeSpan +import android.text.style.SuperscriptSpan import androidx.annotation.StringRes +import androidx.core.text.buildSpannedString +import androidx.core.text.inSpans import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.getDisplayName +import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.toTitleCase import java.util.Locale - -fun MangaSource.getLocaleTitle(): String? { - val lc = Locale(locale ?: return null) - return lc.getDisplayLanguage(lc).toTitleCase(lc) -} +import com.google.android.material.R as materialR fun MangaSource(name: String): MangaSource { MangaSource.entries.forEach { @@ -33,6 +39,24 @@ val ContentType.titleResId fun MangaSource.getSummary(context: Context): String { val type = context.getString(contentType.titleResId) - val locale = getLocaleTitle() ?: context.getString(R.string.various_languages) + val locale = locale?.toLocale().getDisplayName(context) return context.getString(R.string.source_summary_pattern, type, locale) } + +fun MangaSource.getTitle(context: Context): CharSequence = if (isNsfw()) { + buildSpannedString { + append(title) + append(' ') + appendNsfwLabel(context) + } +} else { + title +} + +private fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans( + ForegroundColorSpan(context.getThemeColor(materialR.attr.colorError, Color.RED)), + RelativeSizeSpan(0.74f), + SuperscriptSpan(), +) { + append(context.getString(R.string.nsfw)) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaDataRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaDataRepository.kt index 8a365547c..ba2b08223 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaDataRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaDataRepository.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.core.parser +import androidx.core.net.toUri import androidx.room.withTransaction import dagger.Reusable 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.model.isLocal 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.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag @@ -97,9 +99,18 @@ class MangaDataRepository @Inject constructor( 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? { - return if (cfBrightness != 0f || cfContrast != 0f || cfInvert) { - ReaderColorFilter(cfBrightness, cfContrast, cfInvert) + return if (cfBrightness != 0f || cfContrast != 0f || cfInvert || cfGrayscale) { + ReaderColorFilter(cfBrightness, cfContrast, cfInvert, cfGrayscale) } else { null } @@ -111,5 +122,6 @@ class MangaDataRepository @Inject constructor( cfBrightness = 0f, cfContrast = 0f, cfInvert = false, + cfGrayscale = false, ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt index 8f66ff6be..4b5e3c906 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaRepository.kt @@ -15,6 +15,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import java.lang.ref.WeakReference import java.util.EnumMap +import java.util.Locale import javax.inject.Inject import javax.inject.Singleton import kotlin.collections.set @@ -41,6 +42,8 @@ interface MangaRepository { suspend fun getTags(): Set + suspend fun getLocales(): Set + suspend fun getRelated(seed: Manga): List @Singleton diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt index fffe8ef06..a3bdb3a5e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/RemoteMangaRepository.kt @@ -32,6 +32,7 @@ import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import java.util.Locale class RemoteMangaRepository( private val parser: MangaParser, @@ -104,6 +105,10 @@ class RemoteMangaRepository( parser.getAvailableTags() } + override suspend fun getLocales(): Set { + return parser.getAvailableLocales() + } + suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching { parser.getFavicons() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 0512630c3..01d109aa5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.core.prefs -import android.annotation.SuppressLint import android.content.Context import android.content.SharedPreferences import android.net.ConnectivityManager @@ -14,16 +13,12 @@ import androidx.core.content.edit import androidx.core.os.LocaleListCompat import androidx.preference.PreferenceManager import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import org.json.JSONArray import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.util.ext.connectivityManager import org.koitharu.kotatsu.core.util.ext.getEnumValue import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.core.util.ext.takeIfReadable import org.koitharu.kotatsu.core.util.ext.toUriOrNull @@ -301,22 +296,19 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { var readerColorFilter: ReaderColorFilter? get() { - if (!prefs.getBoolean(KEY_CF_ENABLED, false)) { - return null - } - val brightness = prefs.getFloat(KEY_CF_BRIGHTNESS, 0f) - val contrast = prefs.getFloat(KEY_CF_CONTRAST, 0f) - val inverted = prefs.getBoolean(KEY_CF_INVERTED, false) - return ReaderColorFilter(brightness, contrast, inverted) + 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 { - putBoolean(KEY_CF_ENABLED, value != null) - if (value != null) { - putFloat(KEY_CF_BRIGHTNESS, value.brightness) - putFloat(KEY_CF_CONTRAST, value.contrast) - putBoolean(KEY_CF_INVERTED, value.isInverted) - } + 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) } } @@ -452,17 +444,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { return result } - @SuppressLint("ApplySharedPref") - private inline fun SharedPreferences.editAsync( - action: SharedPreferences.Editor.() -> Unit - ) { - val editor = edit() - action(editor) - processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) { - editor.commit() - } - } - companion object { const val PAGE_SWITCH_TAPS = "taps" @@ -573,10 +554,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_32BIT_COLOR = "enhanced_colors" const val KEY_SOURCES_ORDER = "sources_sort_order" const val KEY_SOURCES_CATALOG = "sources_catalog" - const val KEY_CF_ENABLED = "cf_enabled" 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 const val KEY_APP_UPDATE = "app_update" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/ReorderableListAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/ReorderableListAdapter.kt new file mode 100644 index 000000000..06a744fd9 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/ReorderableListAdapter.kt @@ -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 : ListDelegationAdapter>(), FlowCollector?> { + + private val listListeners = LinkedList>() + + override suspend fun emit(value: List?) { + 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?) { + super.setItems(items) + } + + fun reorderItems(oldPos: Int, newPos: Int) { + Collections.swap(items ?: return, oldPos, newPos) + notifyItemMoved(oldPos, newPos) + } + + fun addDelegate(type: ListItemType, delegate: AdapterDelegate>): ReorderableListAdapter { + delegatesManager.addDelegate(type.ordinal, delegate) + return this + } + + fun addListListener(listListener: ListListener) { + listListeners.add(listListener) + } + + fun removeListListener(listListener: ListListener) { + listListeners.remove(listListener) + } + + protected class DiffCallback( + val oldList: List, + val newList: List, + ) : 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 + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/drawable/TextDrawable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/drawable/TextDrawable.kt deleted file mode 100644 index 21b5835e7..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/drawable/TextDrawable.kt +++ /dev/null @@ -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 } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/NestedScrollStateHandle.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/NestedScrollStateHandle.kt deleted file mode 100644 index b4946ccb0..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/NestedScrollStateHandle.kt +++ /dev/null @@ -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 = savedInstanceState?.let { - BundleCompat.getSparseParcelableArray(it, key, Parcelable::class.java) - } ?: SparseArray() - private val controllers = Collections.newSetFromMap(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) - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/SectionedSelectionController.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/SectionedSelectionController.kt index 066b4fa59..4ec8cc011 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/SectionedSelectionController.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/SectionedSelectionController.kt @@ -1,237 +1,4 @@ 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" -class SectionedSelectionController( - private val activity: Activity, - private val owner: SavedStateRegistryOwner, - private val callback: Callback, -) : ActionMode.Callback, SavedStateRegistry.SavedStateProvider { - - private var actionMode: ActionMode? = null - - private var pendingData: MutableMap>? = null - private val decorations = ArrayMap() - - val count: Int - get() = decorations.values.sumOf { it.checkedItemsCount } - - init { - owner.lifecycle.addObserver(StateEventObserver()) - } - - fun snapshot(): Map> { - return decorations.mapValues { it.value.checkedItemsIds.toSet() } - } - - fun peekCheckedIds(): Map> { - 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): 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>) { - 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 { - - fun onSelectionChanged(controller: SectionedSelectionController, count: Int) - - fun onCreateActionMode(controller: SectionedSelectionController, mode: ActionMode, menu: Menu): Boolean - - fun onPrepareActionMode(controller: SectionedSelectionController, mode: ActionMode, menu: Menu): Boolean { - mode.title = controller.count.toString() - return true - } - - fun onDestroyActionMode(controller: SectionedSelectionController, mode: ActionMode) = Unit - - fun onActionItemClicked( - controller: SectionedSelectionController, - mode: ActionMode, - item: MenuItem, - ): Boolean - - fun onCreateItemDecoration( - controller: SectionedSelectionController, - 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() }, - ) - } - } - } - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScroller.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScroller.kt index 454b5c453..55479d2d2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScroller.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScroller.kt @@ -12,7 +12,12 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup 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.ConstraintSet import androidx.coordinatorlayout.widget.CoordinatorLayout @@ -131,19 +136,19 @@ class FastScroller @JvmOverloads constructor( var showTrack = false - context.withStyledAttributes(attrs, R.styleable.FastScroller, defStyleAttr) { - bubbleColor = getColor(R.styleable.FastScroller_bubbleColor, bubbleColor) - handleColor = getColor(R.styleable.FastScroller_thumbColor, handleColor) - trackColor = getColor(R.styleable.FastScroller_trackColor, trackColor) - textColor = getColor(R.styleable.FastScroller_bubbleTextColor, textColor) - hideScrollbar = getBoolean(R.styleable.FastScroller_hideScrollbar, hideScrollbar) - showBubble = getBoolean(R.styleable.FastScroller_showBubble, showBubble) - showBubbleAlways = getBoolean(R.styleable.FastScroller_showBubbleAlways, showBubbleAlways) - showTrack = getBoolean(R.styleable.FastScroller_showTrack, showTrack) - bubbleSize = getBubbleSize(R.styleable.FastScroller_bubbleSize, BubbleSize.NORMAL) - val textSize = getDimension(R.styleable.FastScroller_bubbleTextSize, bubbleSize.textSize) + context.withStyledAttributes(attrs, R.styleable.FastScrollRecyclerView, defStyleAttr) { + bubbleColor = getColor(R.styleable.FastScrollRecyclerView_bubbleColor, bubbleColor) + handleColor = getColor(R.styleable.FastScrollRecyclerView_thumbColor, handleColor) + trackColor = getColor(R.styleable.FastScrollRecyclerView_trackColor, trackColor) + textColor = getColor(R.styleable.FastScrollRecyclerView_bubbleTextColor, textColor) + hideScrollbar = getBoolean(R.styleable.FastScrollRecyclerView_hideScrollbar, hideScrollbar) + showBubble = getBoolean(R.styleable.FastScrollRecyclerView_showBubble, showBubble) + showBubbleAlways = getBoolean(R.styleable.FastScrollRecyclerView_showBubbleAlways, showBubbleAlways) + showTrack = getBoolean(R.styleable.FastScrollRecyclerView_showTrack, showTrack) + bubbleSize = getBubbleSize(R.styleable.FastScrollRecyclerView_bubbleSize, BubbleSize.NORMAL) + val textSize = getDimension(R.styleable.FastScrollRecyclerView_bubbleTextSize, bubbleSize.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) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt index a5e8c2d43..aa3ce78d1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt @@ -70,7 +70,7 @@ class WindowInsetsDelegate : OnApplyWindowInsetsListener, View.OnLayoutChangeLis lastInsets = null } - interface WindowInsetsListener { + fun interface WindowInsetsListener { fun onWindowInsetsChanged(insets: Insets) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt index c2689a1ed..822e2f688 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt @@ -1,21 +1,16 @@ package org.koitharu.kotatsu.core.ui.widgets -import android.annotation.SuppressLint import android.content.Context -import android.content.res.ColorStateList import android.util.AttributeSet import android.view.View.OnClickListener import androidx.annotation.ColorRes import androidx.annotation.DrawableRes -import androidx.core.content.ContextCompat -import androidx.core.content.res.getColorStateListOrThrow import androidx.core.view.children import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipGroup import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.castOrNull -import com.google.android.material.R as materialR class ChipsView @JvmOverloads constructor( context: Context, @@ -31,9 +26,7 @@ class ChipsView @JvmOverloads constructor( private val chipOnCloseListener = OnClickListener { onChipCloseClickListener?.onChipCloseClick(it as Chip, it.tag) } - private val defaultChipStrokeColor: ColorStateList - private val defaultChipTextColor: ColorStateList - private val defaultChipIconTint: ColorStateList + private val chipStyle: Int var onChipClickListener: OnChipClickListener? = null set(value) { field = value @@ -48,12 +41,17 @@ class ChipsView @JvmOverloads constructor( } init { - @SuppressLint("CustomViewStyleable") - val a = context.obtainStyledAttributes(null, materialR.styleable.Chip, 0, R.style.Widget_Kotatsu_Chip) - defaultChipStrokeColor = a.getColorStateListOrThrow(materialR.styleable.Chip_chipStrokeColor) - defaultChipTextColor = a.getColorStateListOrThrow(materialR.styleable.Chip_android_textColor) - defaultChipIconTint = a.getColorStateListOrThrow(materialR.styleable.Chip_chipIconTint) - a.recycle() + val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0) + chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip) + ta.recycle() + + if (isInEditMode) { + setChips( + List(5) { + ChipModel(0, "Chip $it", 0, isCheckable = false, isChecked = false) + }, + ) + } } override fun requestLayout() { @@ -91,15 +89,6 @@ class ChipsView @JvmOverloads constructor( private fun bindChip(chip: Chip, model: ChipModel) { 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.isCheckable = model.isCheckable if (model.icon == 0) { @@ -115,12 +104,11 @@ class ChipsView @JvmOverloads constructor( private fun addChip(): Chip { val chip = Chip(context) - val drawable = ChipDrawable.createFromAttributes(context, null, 0, R.style.Widget_Kotatsu_Chip) + val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle) chip.setChipDrawable(drawable) chip.isCheckedIconVisible = true chip.isChipIconVisible = false chip.setCheckedIconResource(R.drawable.ic_check) - chip.checkedIconTint = defaultChipIconTint chip.isCloseIconVisible = onChipCloseClickListener != null chip.setOnCloseIconClickListener(chipOnCloseListener) chip.setEnsureMinTouchTargetSize(false) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TipView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TipView.kt index 96174748a..53e5312de 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TipView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/TipView.kt @@ -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.textAndVisible import org.koitharu.kotatsu.databinding.ViewTipBinding -import com.google.android.material.R as materialR class TipView @JvmOverloads constructor( context: Context, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/LocaleComparator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/LocaleComparator.kt index a92b50445..eac57d93d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/LocaleComparator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/LocaleComparator.kt @@ -4,23 +4,20 @@ import androidx.core.os.LocaleListCompat import org.koitharu.kotatsu.core.util.ext.map import java.util.Locale -class LocaleComparator : Comparator { +class LocaleComparator : Comparator { private val deviceLocales = LocaleListCompat.getAdjustedDefault()//LocaleManagerCompat.getSystemLocales(context) .map { it.language } .distinct() - override fun compare(a: Locale?, b: Locale?): Int { - return if (a === b) { - 0 - } else { - val indexA = if (a == null) -1 else deviceLocales.indexOf(a.language) - val indexB = if (b == null) -1 else deviceLocales.indexOf(b.language) - if (indexA < 0 && indexB < 0) { - compareValues(a?.language, b?.language) - } else { - -2 - (indexA - indexB) - } + override fun compare(a: Locale, b: Locale): Int { + val indexA = deviceLocales.indexOf(a.language) + val indexB = deviceLocales.indexOf(b.language) + return when { + indexA < 0 && indexB < 0 -> compareValues(a.language, b.language) + indexA < 0 -> 1 + indexB < 0 -> -1 + else -> compareValues(indexA, indexB) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ViewBadge.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ViewBadge.kt index 3b8ccf955..fd7554006 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ViewBadge.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ViewBadge.kt @@ -21,7 +21,11 @@ class ViewBadge( get() = badgeDrawable?.number ?: 0 set(value) { val badge = badgeDrawable ?: initBadge() - badge.number = value + if (maxCharacterCount != 0) { + badge.number = value + } else { + badge.clearNumber() + } badge.isVisible = value > 0 } @@ -51,7 +55,13 @@ class ViewBadge( fun setMaxCharacterCount(value: Int) { maxCharacterCount = value - badgeDrawable?.maxCharacterCount = value + badgeDrawable?.let { + if (value == 0) { + it.clearNumber() + } else { + it.maxCharacterCount = value + } + } } private fun initBadge(): BadgeDrawable { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt index 92bca0fd4..81e84e385 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt @@ -130,7 +130,7 @@ fun Window.setNavigationBarTransparentCompat(context: Context, elevation: Float, } else { // Set navbar scrim 70% of navigationBarColor ElevationOverlayProvider(context).compositeOverlayIfNeeded( - context.getThemeColor(android.R.attr.navigationBarColor, alphaFactor), + context.getThemeColor(R.attr.m3ColorBottomMenuBackground, alphaFactor), elevation, ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ContentResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ContentResolver.kt index 4a0085b17..3f273a060 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ContentResolver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ContentResolver.kt @@ -50,7 +50,7 @@ private fun getVolumePathBeforeAndroid11(volumeId: String, context: Context): St val length = ArrayReflect.getLength(checkNotNull(result)) (0 until length).firstNotNullOfOrNull { i -> val storageVolumeElement = ArrayReflect.get(result, i) - val uuid = getUuid.invoke(storageVolumeElement) as String + val uuid = getUuid.invoke(storageVolumeElement) as String? val primary = isPrimary.invoke(storageVolumeElement) as Boolean when { primary && volumeId == PRIMARY_VOLUME_NAME -> getPath.invoke(storageVolumeElement) as String diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/LocaleList.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/LocaleList.kt index c9e0cb3ef..7d817c6c1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/LocaleList.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/LocaleList.kt @@ -20,12 +20,13 @@ inline fun LocaleListCompat.mapToSet(block: (Locale) -> T): Set { fun LocaleListCompat.getOrThrow(index: Int) = get(index) ?: throw NoSuchElementException() -fun String?.getLocaleDisplayName(context: Context): String { +fun String.toLocale() = Locale(this) + +fun Locale?.getDisplayName(context: Context): String { if (this == null) { return context.getString(R.string.various_languages) } - val lc = Locale(this) - return lc.getDisplayLanguage(lc).toTitleCase(lc) + return getDisplayLanguage(this).toTitleCase(this) } private class LocaleListCompatIterator(private val list: LocaleListCompat) : ListIterator { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt index c8d2a55ad..21e6e838e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt @@ -19,6 +19,11 @@ import org.koitharu.kotatsu.core.exceptions.SyncApiException import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException import org.koitharu.kotatsu.core.exceptions.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.ContentUnavailableException 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 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) { 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.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 == 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_GENRES_NOT_SUPPORTED -> resources.getString(R.string.error_multiple_genres_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 == 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 } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt index 4ffaca0d9..ff519da76 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt @@ -104,6 +104,7 @@ fun RecyclerView.invalidateNestedItemDecorations() { val View.parentView: ViewGroup? get() = parent as? ViewGroup +@Suppress("UnusedReceiverParameter") fun View.measureDimension(desiredSize: Int, measureSpec: Int): Int { var result: Int val specMode = MeasureSpec.getMode(measureSpec) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ProgressJob.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ProgressJob.kt deleted file mode 100644 index 826916ddf..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/ProgressJob.kt +++ /dev/null @@ -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

( - private val job: Job, - private val progress: StateFlow

, -) : Job by job { - - val progressValue: P - get() = progress.value - - fun progressAsFlow(): Flow

= progress -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt index e2944b6fd..385c7e416 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt @@ -91,7 +91,7 @@ class MangaPrefetchService : CoroutineIntentService() { val intent = Intent(context, MangaPrefetchService::class.java) intent.action = ACTION_PREFETCH_DETAILS intent.putExtra(EXTRA_MANGA, ParcelableManga(manga)) - context.startService(intent) + tryStart(context, intent) } fun prefetchPages(context: Context, chapter: MangaChapter) { @@ -99,19 +99,14 @@ class MangaPrefetchService : CoroutineIntentService() { val intent = Intent(context, MangaPrefetchService::class.java) intent.action = ACTION_PREFETCH_PAGES intent.putExtra(EXTRA_CHAPTER, ParcelableChapter(chapter)) - try { - context.startService(intent) - } catch (e: IllegalStateException) { - // probably app is in background - e.printStackTraceDebug() - } + tryStart(context, intent) } fun prefetchLast(context: Context) { if (!isPrefetchAvailable(context, null)) return val intent = Intent(context, MangaPrefetchService::class.java) intent.action = ACTION_PREFETCH_LAST - context.startService(intent) + tryStart(context, intent) } private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean { @@ -127,5 +122,14 @@ class MangaPrefetchService : CoroutineIntentService() { ) return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled } + + private fun tryStart(context: Context, intent: Intent) { + try { + context.startService(intent) + } catch (e: IllegalStateException) { + // probably app is in background + e.printStackTraceDebug() + } + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt index c8c3f59a4..87fb0f8a5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersFragment.kt @@ -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.ext.observe 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.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index 1cce50a79..0a432b28c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -53,6 +53,7 @@ import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus @@ -134,8 +135,14 @@ class DetailsViewModel @Inject constructor( .map { it?.local } .distinctUntilChanged() .map { local -> - local?.file?.computeSize() ?: 0L - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(), 0) + if (local != null) { + runCatchingCancellable { + local.file.computeSize() + }.getOrDefault(0L) + } else { + 0L + } + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(5000), 0L) @Deprecated("") val description = details diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/BranchAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/BranchAD.kt deleted file mode 100644 index d471a5c0b..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/BranchAD.kt +++ /dev/null @@ -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, -) = adapterDelegateViewBinding( - { 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 - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt index fbe80b9d4..2c11ee18f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt @@ -6,7 +6,6 @@ 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.drawableEnd import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.textAndVisible diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt index cc87f160b..271b3090d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt @@ -5,7 +5,9 @@ import androidx.core.view.isGone import androidx.core.view.isVisible 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 coil.ImageLoader import coil.request.SuccessResult @@ -59,6 +61,7 @@ fun downloadItemAD( val chaptersAdapter = BaseListAdapter() .addDelegate(ListItemType.CHAPTER, downloadChapterAD()) + binding.recyclerViewChapters.addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL)) binding.recyclerViewChapters.adapter = chaptersAdapter binding.buttonCancel.setOnClickListener(clickListener) binding.buttonPause.setOnClickListener(clickListener) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt index b86328610..c03313734 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/PausingReceiver.kt @@ -21,7 +21,8 @@ class PausingReceiver( return } when (intent.action) { - ACTION_RESUME -> pausingHandle.resume(intent.getBooleanExtra(EXTRA_SKIP_ERROR, false)) + ACTION_RESUME -> pausingHandle.resume(skipError = false) + ACTION_SKIP -> pausingHandle.resume(skipError = true) ACTION_PAUSE -> pausingHandle.pause() } } @@ -30,13 +31,14 @@ class PausingReceiver( private const val ACTION_PAUSE = "org.koitharu.kotatsu.download.PAUSE" 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_SKIP_ERROR = "skip" private const val SCHEME = "workuid" fun createIntentFilter(id: UUID) = IntentFilter().apply { addAction(ACTION_PAUSE) addAction(ACTION_RESUME) + addAction(ACTION_SKIP) addDataScheme(SCHEME) addDataPath(id.toString(), PatternMatcher.PATTERN_SIMPLE_GLOB) } @@ -46,11 +48,11 @@ class PausingReceiver( .setPackage(context.packageName) .putExtra(EXTRA_UUID, id.toString()) - fun getResumeIntent(context: Context, id: UUID, skipError: Boolean) = Intent(ACTION_RESUME) - .setData(Uri.parse("$SCHEME://$id")) + fun getResumeIntent(context: Context, id: UUID, skipError: Boolean) = Intent( + if (skipError) ACTION_SKIP else ACTION_RESUME, + ).setData(Uri.parse("$SCHEME://$id")) .setPackage(context.packageName) .putExtra(EXTRA_UUID, id.toString()) - .putExtra(EXTRA_SKIP_ERROR, skipError) fun createPausePendingIntent(context: Context, id: UUID) = PendingIntentCompat.getBroadcast( context, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt index ef3bc0a5e..b90f7f5e2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt @@ -111,7 +111,7 @@ class ExploreFragment : } override fun onListHeaderClick(item: ListHeader, view: View) { - startActivity(Intent(view.context, SourcesCatalogActivity::class.java)) + startActivity(SettingsActivity.newManageSourcesIntent(view.context)) } override fun onPrimaryButtonClick(tipView: TipView) { @@ -160,7 +160,7 @@ class ExploreFragment : override fun onRetryClick(error: Throwable) = Unit override fun onEmptyActionClick() { - startActivity(SettingsActivity.newManageSourcesIntent(context ?: return)) + startActivity(Intent(context ?: return, SourcesCatalogActivity::class.java)) } private fun onOpenManga(manga: Manga) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt index 218df582d..00866ccc6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt @@ -21,7 +21,6 @@ import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.explore.data.MangaSourcesRepository -import org.koitharu.kotatsu.explore.data.SourcesSortOrder import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.ui.model.ExploreButtons import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem @@ -56,8 +55,6 @@ class ExploreViewModel @Inject constructor( valueProducer = { isSuggestionsEnabled }, ) - val sortOrder = MutableStateFlow(SourcesSortOrder.MANUAL) // TODO - val onOpenManga = MutableEventFlow() val onActionDone = MutableEventFlow() val onShowSuggestionsTip = MutableEventFlow() @@ -136,7 +133,7 @@ class ExploreViewModel @Inject constructor( result += RecommendationsItem(recommendation) } if (sources.isNotEmpty()) { - result += ListHeader(R.string.remote_sources, R.string.catalog) + result += ListHeader(R.string.remote_sources, R.string.manage) if (newSources.isNotEmpty()) { result += TipModel( key = TIP_NEW_SOURCES, @@ -153,7 +150,7 @@ class ExploreViewModel @Inject constructor( icon = R.drawable.ic_empty_common, textPrimary = R.string.no_manga_sources, textSecondary = R.string.no_manga_sources_text, - actionStringRes = R.string.manage, + actionStringRes = R.string.catalog, ) } return result diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt index ec68c5823..735f32df1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/adapter/ExploreAdapterDelegates.kt @@ -8,6 +8,7 @@ import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.getSummary +import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.ui.image.FaviconDrawable import org.koitharu.kotatsu.core.ui.image.TrimTransformation @@ -112,7 +113,7 @@ fun exploreSourceListItemAD( binding.root.setOnContextClickListenerCompat(eventListener) bind { - binding.textViewTitle.text = item.source.title + binding.textViewTitle.text = item.source.getTitle(context) binding.textViewSubtitle.text = item.source.getSummary(context) val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name) binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run { @@ -147,7 +148,7 @@ fun exploreSourceGridItemAD( binding.root.setOnContextClickListenerCompat(eventListener) bind { - binding.textViewTitle.text = item.source.title + binding.textViewTitle.text = item.source.getTitle(context) val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Large, item.source.name) binding.imageViewIcon.newImageRequest(lifecycleOwner, item.source.faviconUri())?.run { fallback(fallbackIcon) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt index 7de530222..5e1f0982f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt @@ -142,7 +142,11 @@ class FavouriteCategoriesActivity : } val fromPos = viewHolder.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( @@ -151,25 +155,16 @@ class FavouriteCategoriesActivity : target: RecyclerView.ViewHolder, ): 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 onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { super.onSelectedChanged(viewHolder, actionState) - viewBinding.recyclerView.isNestedScrollingEnabled = - actionState == ItemTouchHelper.ACTION_STATE_IDLE + viewBinding.recyclerView.isNestedScrollingEnabled = actionState == ItemTouchHelper.ACTION_STATE_IDLE + } + + override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + super.clearView(recyclerView, viewHolder) + viewModel.saveOrder(adapter.items ?: return) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt index dcc9b1097..27fa877ee 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouritesCategoriesViewModel.kt @@ -1,13 +1,14 @@ package org.koitharu.kotatsu.favourites.ui.categories +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.yield +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.prefs.AppSettings @@ -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.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.parsers.util.move import javax.inject.Inject @HiltViewModel @@ -30,17 +30,9 @@ class FavouritesCategoriesViewModel @Inject constructor( private var commitJob: Job? = null - val content = MutableStateFlow>(listOf(LoadingState)) - - init { - launchJob(Dispatchers.Default) { - repository.observeCategoriesWithCovers() - .collectLatest { - commitJob?.join() - updateContent(it) - } - } - } + val content = repository.observeCategoriesWithCovers() + .map { it.toUiList() } + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) fun deleteCategories(ids: Set) { launchJob(Dispatchers.Default) { @@ -54,11 +46,17 @@ class FavouritesCategoriesViewModel @Inject constructor( fun isEmpty(): Boolean = content.value.none { it is CategoryListModel } - fun reorderCategories(oldPos: Int, newPos: Int) { - val snapshot = content.requireValue().toMutableList() - snapshot.move(oldPos, newPos) - content.value = snapshot - commit(snapshot) + fun saveOrder(snapshot: List) { + val prevJob = commitJob + commitJob = launchJob { + prevJob?.cancelAndJoin() + val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) { + (it as? CategoryListModel)?.category?.id + } + if (ids.isNotEmpty()) { + repository.reorderCategories(ids) + } + } } fun setIsVisible(ids: Set, isVisible: Boolean) { @@ -76,36 +74,21 @@ class FavouritesCategoriesViewModel @Inject constructor( } } - private fun commit(snapshot: List) { - val prevJob = commitJob - commitJob = launchJob { - prevJob?.cancelAndJoin() - delay(500) - val ids = snapshot.mapNotNullTo(ArrayList(snapshot.size)) { - (it as? CategoryListModel)?.category?.id - } - repository.reorderCategories(ids) - yield() - } - } - - private fun updateContent(categories: Map>) { - 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, - ), - ) - } + private fun Map>.toUiList(): List = 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, + ), + ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt index 46111fbe0..43658d3a3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoriesAdapter.kt @@ -2,7 +2,7 @@ package org.koitharu.kotatsu.favourites.ui.categories.adapter import androidx.lifecycle.LifecycleOwner 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.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener @@ -15,7 +15,7 @@ class CategoriesAdapter( lifecycleOwner: LifecycleOwner, onItemClickListener: FavouriteCategoriesListListener, listListener: ListStateHolderListener, -) : BaseListAdapter() { +) : ReorderableListAdapter() { init { addDelegate(ListItemType.CATEGORY_LARGE, categoryAD(coil, lifecycleOwner, onItemClickListener)) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt index ec93fcaef..253827e88 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt @@ -65,10 +65,7 @@ fun categoryAD( binding.imageViewEdit.setOnClickListener(eventListener) binding.imageViewHandle.setOnTouchListener(eventListener) - bind { payloads -> - if (payloads.isNotEmpty()) { - return@bind - } + bind { binding.textViewTitle.text = item.category.title binding.textViewSubtitle.text = if (item.mangaCount == 0) { getString(R.string.empty) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt index cc878a1ac..2b7ad5686 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt @@ -15,13 +15,13 @@ fun categoriesHeaderAD() = adapterDelegateViewBinding val intent = when (v.id) { - R.id.button_create -> FavouritesCategoryEditActivity.newIntent(v.context) - R.id.button_manage -> FavouriteCategoriesActivity.newIntent(v.context) + R.id.chip_create -> FavouritesCategoryEditActivity.newIntent(v.context) + R.id.chip_manage -> FavouriteCategoriesActivity.newIntent(v.context) else -> return@OnClickListener } v.context.startActivity(intent) } - binding.buttonCreate.setOnClickListener(onClickListener) - binding.buttonManage.setOnClickListener(onClickListener) + binding.chipCreate.setOnClickListener(onClickListener) + binding.chipManage.setOnClickListener(onClickListener) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt index 0042fe95c..67b269c0d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt @@ -20,7 +20,7 @@ fun mangaCategoryAD( } 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.imageViewTracker.isVisible = item.category.isTrackingEnabled && item.isTrackerEnabled binding.imageViewVisible.isVisible = item.category.isVisibleInLibrary diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapter.kt deleted file mode 100644 index 67280aaa2..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapter.kt +++ /dev/null @@ -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, -) : BaseListAdapter(), 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 - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapterDelegates.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapterDelegates.kt deleted file mode 100644 index b205a336a..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterAdapterDelegates.kt +++ /dev/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( - { 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( - { 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( - { 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( - { 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(R.layout.item_sources_empty) { - - bind { - (itemView as TextView).setText(item.textResId) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt index 784cc4330..dec244837 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt @@ -1,39 +1,50 @@ package org.koitharu.kotatsu.filter.ui import android.view.View -import androidx.annotation.WorkerThread import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.ViewModelLifecycle import dagger.hilt.android.scopes.ViewModelScoped import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository 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.printStackTraceDebug 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.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.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter 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.MangaState 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.runCatchingCancellable import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment @@ -55,16 +66,84 @@ class FilterCoordinator @Inject constructor( private val coroutineScope = lifecycle.lifecycleScope private val repository = mangaRepositoryFactory.create(savedStateHandle.require(RemoteListFragment.ARG_SOURCE)) - private val currentState = - MutableStateFlow(MangaListFilter.Advanced(repository.defaultSortOrder, emptySet(), null, emptySet())) - private var searchQuery = MutableStateFlow("") + private val currentState = MutableStateFlow( + MangaListFilter.Advanced(repository.defaultSortOrder, emptySet(), null, emptySet()), + ) private val localTags = SuspendLazy { dataRepository.findTags(repository.source) } private var availableTagsDeferred = loadTagsAsync() + private var availableLocalesDeferred = loadLocalesAsync() + private var allTagsLoadJob: Job? = null + + override val allTags = MutableStateFlow>(listOf(LoadingState)) + get() { + if (allTagsLoadJob == null || field.value.any { it is ErrorFooter }) { + loadAllTags() + } + return field + } + + override val filterTags: StateFlow> = 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 filterItems: StateFlow> = getItemsFlow() - .stateIn(coroutineScope + Dispatchers.Default, SharingStarted.Lazily, listOf(LoadingState)) + override val filterSortOrder: StateFlow> = 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> = 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> = combine( + currentState.distinctUntilChangedBy { it.locale }, + getLocalesAsFlow(), + ) { state, locales -> + val list = if (locales.items.isNotEmpty()) { + val l = ArrayList(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 = getHeaderFlow().stateIn( scope = coroutineScope + Dispatchers.Default, @@ -72,49 +151,52 @@ class FilterCoordinator @Inject constructor( initialValue = FilterHeaderModel( chips = emptyList(), sortOrder = repository.defaultSortOrder, - hasSelectedTags = false, - allowMultipleTags = repository.isMultipleTagsSupported, + isFilterApplied = false, ), ) - init { - observeState() - } - override fun applyFilter(tags: Set) { setTags(tags) } - override fun onSortItemClick(item: FilterItem.Sort) { + override fun setSortOrder(value: SortOrder) { 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 -> - val newTags = if (!item.isMultiple) { - if (isFromChip && item.isChecked) { - emptySet() + oldValue.copy(locale = value) + } + } + + override fun setTag(value: MangaTag, addOrRemove: Boolean) { + currentState.update { oldValue -> + val newTags = if (repository.isMultipleTagsSupported) { + if (addOrRemove) { + oldValue.tags + value } else { - setOf(item.tag) + oldValue.tags - value } - } else if (item.isChecked) { - oldValue.tags - item.tag } else { - oldValue.tags + item.tag + if (addOrRemove) { + setOf(value) + } else { + emptySet() + } } oldValue.copy(tags = newTags) } } - override fun onStateItemClick(item: FilterItem.State) { + override fun setState(value: MangaState, addOrRemove: Boolean) { currentState.update { oldValue -> - val newStates = if (item.isChecked) { - oldValue.states - item.state + val newStates = if (addOrRemove) { + oldValue.states + value } else { - oldValue.states + item.state + oldValue.states - value } oldValue.copy(states = newStates) } @@ -125,7 +207,7 @@ class FilterCoordinator @Inject constructor( oldValue.copy( sortOrder = oldValue.sortOrder, 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, ) } @@ -135,7 +217,7 @@ class FilterCoordinator @Inject constructor( if (!availableTagsDeferred.isCompleted) { emit(emptySet()) } - emit(availableTagsDeferred.await()) + emit(availableTagsDeferred.await().getOrNull()) } fun observeState() = currentState.asStateFlow() @@ -154,10 +236,6 @@ class FilterCoordinator @Inject constructor( fun snapshot() = currentState.value - fun performSearch(query: String) { - searchQuery.value = query - } - private fun getHeaderFlow() = combine( observeState(), observeAvailableTags(), @@ -166,28 +244,46 @@ class FilterCoordinator @Inject constructor( FilterHeaderModel( chips = chips, sortOrder = state.sortOrder, - hasSelectedTags = state.tags.isNotEmpty(), - allowMultipleTags = repository.isMultipleTagsSupported, + isFilterApplied = !state.isEmpty(), ) } - private fun getItemsFlow() = combine( - getTagsAsFlow(), - currentState, - searchQuery, - ) { tags, state, query -> - buildFilterList(tags, state, query) - } - private fun getTagsAsFlow() = flow { val localTags = localTags.get() - emit(TagsWrapper(localTags, isLoading = true, isError = false)) - val remoteTags = tryLoadTags() - if (remoteTags == null) { - emit(TagsWrapper(localTags, isLoading = false, isError = true)) - } else { - emit(TagsWrapper(mergeTags(remoteTags, localTags), isLoading = false, isError = false)) + emit(PendingData(localTags, isLoading = true, error = null)) + tryLoadTags() + .onSuccess { remoteTags -> + emit(PendingData(mergeTags(remoteTags, localTags), isLoading = false, error = null)) + }.onFailure { + emit(PendingData(localTags, isLoading = false, error = it)) + } + } + + private fun getLocalesAsFlow(): Flow> = 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>, limit: Int): Flow> = 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( @@ -237,84 +333,40 @@ class FilterCoordinator @Inject constructor( return result } - @WorkerThread - private fun buildFilterList( - allTags: TagsWrapper, - state: MangaListFilter.Advanced, - query: String, - ): List { - val sortOrders = repository.sortOrders.sortedByOrdinal() - val states = repository.states - val tags = mergeTags(state.tags, allTags.tags).toList() - val list = ArrayList(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? { + private suspend fun tryLoadTags(): Result> { val shouldRetryOnError = availableTagsDeferred.isCompleted val result = availableTagsDeferred.await() - if (result == null && shouldRetryOnError) { + if (result.isFailure && shouldRetryOnError) { availableTagsDeferred = loadTagsAsync() return availableTagsDeferred.await() } return result } + private suspend fun tryLoadLocales(): Result> { + 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) { runCatchingCancellable { repository.getTags() }.onFailure { error -> error.printStackTraceDebug() - }.getOrNull() + } + } + + private fun loadLocalesAsync() = coroutineScope.async(Dispatchers.Default, CoroutineStart.LAZY) { + runCatchingCancellable { + repository.getLocales() + }.onFailure { error -> + error.printStackTraceDebug() + } } private fun mergeTags(primary: Set, secondary: Set): Set { @@ -324,12 +376,41 @@ class FilterCoordinator @Inject constructor( return result } - private data class TagsWrapper( - val tags: Set, + private fun loadAllTags() { + 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() + e.toErrorFooter() + } + } + } + + private fun appendTagsList(newTags: Collection, isLoading: Boolean) = allTags.update { oldList -> + val oldTags = oldList.filterIsInstance() + buildList(oldTags.size + newTags.size + if (isLoading) 1 else 0) { + addAll(oldTags) + newTags.mapTo(this) { TagCatalogItem(it, isChecked = false) } + val tempSet = HashSet(size) + removeAll { x -> x is TagCatalogItem && !tempSet.add(x.tag) } + sortBy { (it as TagCatalogItem).tag.title } + if (isLoading) { + add(LoadingFooter()) + } + } + } + + private data class PendingData( + val items: Collection, val isLoading: Boolean, - val isError: Boolean, + val error: Throwable?, ) + private fun loadingProperty() = FilterProperty(emptyList(), emptySet(), true, null) + private class TagTitleComparator(lc: String?) : Comparator { private val collator = lc?.let { Collator.getInstance(Locale(it)) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt index f622effe6..18bbc62c4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt @@ -13,7 +13,7 @@ import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding 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 com.google.android.material.R as materialR @@ -37,10 +37,9 @@ class FilterHeaderFragment : BaseFragment(), ChipsV override fun onChipClick(chip: Chip, data: Any?) { val tag = data as? MangaTag if (tag == null) { - FilterSheetFragment.show(parentFragmentManager) + TagsCatalogSheet.show(parentFragmentManager) } else { - val filterItem = FilterItem.Tag(tag, filter.header.value.allowMultipleTags, !chip.isChecked) - filter.onTagItemClick(filterItem, isFromChip = true) + filter.setTag(tag, chip.isChecked) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterSheetFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterSheetFragment.kt deleted file mode 100644 index bb668af73..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterSheetFragment.kt +++ /dev/null @@ -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(), - AdaptiveSheetCallback, - AsyncListDiffer.ListListener { - - 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, currentList: MutableList) { - 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) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt index e3aa46686..f5f54f682 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/MangaFilter.kt @@ -2,12 +2,24 @@ package org.koitharu.kotatsu.filter.ui import kotlinx.coroutines.flow.StateFlow 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.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import java.util.Locale interface MangaFilter : OnFilterChangedListener { - val filterItems: StateFlow> + val allTags: StateFlow> + + val filterTags: StateFlow> + + val filterSortOrder: StateFlow> + + val filterState: StateFlow> + + val filterLocale: StateFlow> val header: StateFlow diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt index 0b8116512..136c60f05 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/OnFilterChangedListener.kt @@ -1,13 +1,18 @@ 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.parsers.model.MangaState +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import java.util.Locale 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) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterHeaderModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterHeaderModel.kt index 40e9bba4b..468c1130b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterHeaderModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterHeaderModel.kt @@ -3,33 +3,12 @@ package org.koitharu.kotatsu.filter.ui.model import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.parsers.model.SortOrder -class FilterHeaderModel( +data class FilterHeaderModel( val chips: Collection, val sortOrder: SortOrder?, - val hasSelectedTags: Boolean, - val allowMultipleTags: Boolean, + val isFilterApplied: Boolean, ) { val textSummary: String 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 - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterItem.kt deleted file mode 100644 index 536f6f6dc..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterItem.kt +++ /dev/null @@ -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 - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt new file mode 100644 index 000000000..a05157a3d --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/FilterProperty.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.filter.ui.model + +data class FilterProperty( + val availableItems: List, + val selectedItems: Set, + val isLoading: Boolean, + val error: Throwable?, +) { + + fun isEmpty(): Boolean = availableItems.isEmpty() +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/TagCatalogItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/TagCatalogItem.kt new file mode 100644 index 000000000..9cd7fc2b9 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/model/TagCatalogItem.kt @@ -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) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt new file mode 100644 index 000000000..28e13931a --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt @@ -0,0 +1,197 @@ +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.core.view.updatePadding +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(), 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) { + binding.layoutBody.updatePadding(top = binding.layoutBody.paddingBottom) + if (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) { + 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) { + 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) { + 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(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) { + 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) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogAdapter.kt new file mode 100644 index 000000000..1fad15e5b --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogAdapter.kt @@ -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, +) : BaseListAdapter(), 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, + ) = adapterDelegateViewBinding( + { 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) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt new file mode 100644 index 000000000..da03be958 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt @@ -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(), OnListItemClickListener, TextWatcher, + AdaptiveSheetCallback, View.OnFocusChangeListener, TextView.OnEditorActionListener { + + private val viewModel by viewModels( + extrasProducer = { + defaultViewModelCreationExtras.withCreationCallback { 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) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt new file mode 100644 index 000000000..01dbf3741 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogViewModel.kt @@ -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 + } + +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt index d039d5a87..5aae882ae 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt @@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart 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.toManga import org.koitharu.kotatsu.core.db.entity.toMangaTag diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt index a9efc440f..aec6e8055 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt @@ -45,7 +45,7 @@ import javax.inject.Inject @HiltViewModel class HistoryListViewModel @Inject constructor( private val repository: HistoryRepository, - private val settings: AppSettings, + settings: AppSettings, private val extraProvider: ListExtraProvider, private val localMangaRepository: LocalMangaRepository, networkState: NetworkState, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorFooterAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorFooterAD.kt index 4e25a5eb7..aa1a018f7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorFooterAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorFooterAD.kt @@ -7,13 +7,15 @@ import org.koitharu.kotatsu.list.ui.model.ErrorFooter import org.koitharu.kotatsu.list.ui.model.ListModel fun errorFooterAD( - listener: MangaListListener, + listener: MangaListListener?, ) = adapterDelegateViewBinding( { inflater, parent -> ItemErrorFooterBinding.inflate(inflater, parent, false) }, ) { - binding.root.setOnClickListener { - listener.onRetryClick(item.exception) + if (listener != null) { + binding.root.setOnClickListener { + listener.onRetryClick(item.exception) + } } bind { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt index af92052b0..9603e4304 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt @@ -6,6 +6,7 @@ enum class ListItemType { FILTER_TAG, FILTER_TAG_MULTI, FILTER_STATE, + FILTER_LANGUAGE, HEADER, MANGA_LIST, MANGA_LIST_DETAILED, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt index 3251cd0f8..1ec2dacd8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt @@ -31,6 +31,7 @@ class TypedListSpacingDecoration( ListItemType.FILTER_TAG, ListItemType.FILTER_TAG_MULTI, ListItemType.FILTER_STATE, + ListItemType.FILTER_LANGUAGE, -> outRect.set(0) ListItemType.HEADER, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt index 4982ac1cf..cb4640e48 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt @@ -28,10 +28,10 @@ import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.FragmentPreviewBinding import org.koitharu.kotatsu.details.ui.DetailsActivity 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.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.SearchActivity import javax.inject.Inject @@ -57,8 +57,10 @@ class PreviewFragment : BaseFragment(), View.OnClickList binding.textViewAuthor.setOnClickListener(this) binding.imageViewCover.setOnClickListener(this) binding.buttonOpen.setOnClickListener(this) + binding.buttonRead.setOnClickListener(this) viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated) + viewModel.footer.observe(viewLifecycleOwner, ::onFooterUpdated) viewModel.tagsChips.observe(viewLifecycleOwner, ::onTagsChipsChanged) viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged) } @@ -71,6 +73,14 @@ class PreviewFragment : BaseFragment(), View.OnClickList DetailsActivity.newIntent(v.context, manga), ) + R.id.button_read -> { + startActivity( + ReaderActivity.IntentBuilder(v.context) + .manga(manga) + .build(), + ) + } + R.id.textView_author -> startActivity( SearchActivity.newIntent( context = v.context, @@ -98,8 +108,7 @@ class PreviewFragment : BaseFragment(), View.OnClickList if (filter == null) { startActivity(MangaListActivity.newIntent(requireContext(), setOf(tag))) } else { - val filterItem = FilterItem.Tag(tag, filter.header.value.allowMultipleTags, false) - filter.onTagItemClick(filterItem, isFromChip = false) + filter.setTag(tag, true) closeSelf() } } @@ -120,6 +129,43 @@ class PreviewFragment : BaseFragment(), View.OnClickList } } + private fun onFooterUpdated(footer: PreviewViewModel.FooterInfo?) { + with(requireViewBinding()) { + toolbarBottom.isVisible = footer != null + if (footer == null) { + return + } + toolbarBottom.title = when { + footer.isInProgress() -> { + getString(R.string.chapter_d_of_d, footer.currentChapter, footer.totalChapters) + } + + footer.totalChapters > 0 -> { + resources.getQuantityString(R.plurals.chapters, footer.totalChapters, footer.totalChapters) + } + + else -> { + getString(R.string.no_chapters) + } + } + buttonRead.isEnabled = footer.totalChapters > 0 + buttonRead.setIconResource( + when { + footer.isIncognito -> R.drawable.ic_incognito + footer.isInProgress() -> R.drawable.ic_play + else -> R.drawable.ic_read + }, + ) + buttonRead.setText( + if (footer.isInProgress()) { + R.string._continue + } else { + R.string.read + }, + ) + } + } + private fun onDescriptionChanged(description: CharSequence?) { val tv = viewBinding?.textViewDescription ?: return when { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewViewModel.kt index a2856fd3f..e3e03d63b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewViewModel.kt @@ -13,11 +13,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.plus +import org.koitharu.kotatsu.core.model.getPreferredBranch import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaRepository @@ -25,6 +28,7 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.sanitize +import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.list.domain.ListExtraProvider import javax.inject.Inject @@ -33,6 +37,7 @@ class PreviewViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val extraProvider: ListExtraProvider, private val repositoryFactory: MangaRepository.Factory, + private val historyRepository: HistoryRepository, private val imageGetter: Html.ImageGetter, ) : BaseViewModel() { @@ -40,6 +45,26 @@ class PreviewViewModel @Inject constructor( savedStateHandle.require(MangaIntent.KEY_MANGA).manga, ) + val footer = combine( + manga, + historyRepository.observeOne(manga.value.id), + manga.flatMapLatest { historyRepository.observeShouldSkip(it) }.distinctUntilChanged(), + ) { m, history, incognito -> + if (m.chapters == null) { + return@combine null + } + val b = m.getPreferredBranch(history) + val chapters = m.getChapters(b).orEmpty() + FooterInfo( + branch = b, + currentChapter = history?.chapterId?.let { + chapters.indexOfFirst { x -> x.id == it } + } ?: -1, + totalChapters = chapters.size, + isIncognito = incognito, + ) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null) + val description = manga .distinctUntilChangedBy { it.description.orEmpty() } .transformLatest { @@ -82,4 +107,14 @@ class PreviewViewModel @Inject constructor( } return spannable.trim() } + + data class FooterInfo( + val branch: String?, + val currentChapter: Int, + val totalChapters: Int, + val isIncognito: Boolean, + ) { + + fun isInProgress() = currentChapter >= 0 + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt index ad427fb09..e7e32de3f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt @@ -35,6 +35,7 @@ import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.File import java.util.EnumSet +import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -132,7 +133,7 @@ class LocalMangaRepository @Inject constructor( }.getOrNull() } - suspend fun findSavedManga(remoteManga: Manga): LocalManga? { + suspend fun findSavedManga(remoteManga: Manga): LocalManga? = runCatchingCancellable { // fast path LocalMangaInput.find(storageManager.getReadableDirs(), remoteManga)?.let { return it.getManga() @@ -154,12 +155,16 @@ class LocalMangaRepository @Inject constructor( } } }.firstOrNull()?.getManga() - } + }.onFailure { + it.printStackTraceDebug() + }.getOrNull() override suspend fun getPageUrl(page: MangaPage) = page.url override suspend fun getTags() = emptySet() + override suspend fun getLocales() = emptySet() + override suspend fun getRelated(seed: Manga): List = emptyList() suspend fun getOutputDir(manga: Manga): File? { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt index f8e6879ea..6ac62b8de 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaDirInput.kt @@ -20,6 +20,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.toCamelCase import java.io.File +import java.util.TreeMap import java.util.zip.ZipFile /** @@ -49,8 +50,15 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) { url = mangaUri, coverUrl = cover, largeCoverUrl = cover, - chapters = info.chapters?.mapIndexed { i, c -> - c.copy(url = chapterFiles[i].toUri().toString(), source = MangaSource.LOCAL) + chapters = info.chapters?.mapIndexedNotNull { i, c -> + val fileName = index.getChapterFileName(c.id) + val file = if (fileName != null) { + chapterFiles[fileName] + } else { + // old downloads + chapterFiles.values.elementAtOrNull(i) + } ?: return@mapIndexedNotNull null + c.copy(url = file.toUri().toString(), source = MangaSource.LOCAL) }, ) ?: Manga( id = root.absolutePath.longHashCode(), @@ -59,7 +67,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) { publicUrl = mangaUri, source = MangaSource.LOCAL, coverUrl = findFirstImageEntry().orEmpty(), - chapters = chapterFiles.mapIndexed { i, f -> + chapters = chapterFiles.values.mapIndexed { i, f -> MangaChapter( id = "$i${f.name}".longHashCode(), name = f.nameWithoutExtension.toHumanReadable(), @@ -120,9 +128,9 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) { private fun String.toHumanReadable() = replace("_", " ").toCamelCase() - private fun getChaptersFiles(): List = root.walkCompat() + private fun getChaptersFiles() = root.walkCompat() .filter { it.hasCbzExtension() } - .toListSorted(compareBy(AlphanumComparator()) { it.name }) + .associateByTo(TreeMap(AlphanumComparator())) { it.name } private fun findFirstImageEntry(): String? { return root.walkCompat().firstOrNull { hasImageExtension(it) }?.toUri()?.toString() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaInput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaInput.kt index 851ae7b04..45454631a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaInput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaInput.kt @@ -13,6 +13,7 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.toFileNameSafe import java.io.File @@ -54,7 +55,8 @@ sealed class LocalMangaInput( zip.isFile -> LocalMangaZipInput(zip) else -> null } - if (input?.getMangaInfo()?.id == manga.id) { + val info = runCatchingCancellable { input?.getMangaInfo() }.getOrNull() + if (info?.id == manga.id) { send(input) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt index 30c161037..ded98e4f7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt @@ -5,9 +5,11 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import okio.Closeable +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.local.data.input.LocalMangaInput import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.toFileNameSafe import java.io.File @@ -86,7 +88,11 @@ sealed class LocalMangaOutput( } private suspend fun canWriteTo(file: File, manga: Manga): Boolean { - val info = LocalMangaInput.of(file).getMangaInfo() ?: return false + val info = runCatchingCancellable { + LocalMangaInput.of(file).getMangaInfo() + }.onFailure { + it.printStackTraceDebug() + }.getOrNull() ?: return false return info.id == manga.id } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt index 44f799d45..d3f27893f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt @@ -18,8 +18,8 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.FragmentListBinding 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.sheet.FilterSheetFragment import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment @@ -94,7 +94,7 @@ class LocalListFragment : MangaListFragment(), FilterOwner { Snackbar.make( requireViewBinding().recyclerView, R.string.removal_completed, - Snackbar.LENGTH_SHORT + Snackbar.LENGTH_SHORT, ).show() } @@ -103,7 +103,7 @@ class LocalListFragment : MangaListFragment(), FilterOwner { fun newInstance() = LocalListFragment().withArgs(1) { putSerializable( RemoteListFragment.ARG_SOURCE, - MangaSource.LOCAL + MangaSource.LOCAL, ) // required by FilterCoordinator } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalStorageCleanupWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalStorageCleanupWorker.kt index c505d4708..5791a3391 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalStorageCleanupWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalStorageCleanupWorker.kt @@ -12,6 +12,7 @@ import androidx.work.WorkerParameters import androidx.work.await import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository import java.util.concurrent.TimeUnit @@ -20,10 +21,12 @@ class LocalStorageCleanupWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted params: WorkerParameters, private val localMangaRepository: LocalMangaRepository, + private val dataRepository: MangaDataRepository, ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { return if (localMangaRepository.cleanup()) { + dataRepository.cleanupLocalManga() Result.success() } else { Result.retry() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt index 4b86ef110..b140fe2bd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -13,8 +13,6 @@ import androidx.appcompat.view.ActionMode import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.graphics.Insets -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams @@ -47,7 +45,6 @@ import org.koitharu.kotatsu.core.util.ext.hideKeyboard import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf -import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat import org.koitharu.kotatsu.databinding.ActivityMainBinding import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.ui.DetailsActivity @@ -55,6 +52,7 @@ import org.koitharu.kotatsu.history.ui.HistoryListFragment import org.koitharu.kotatsu.local.ui.LocalStorageCleanupWorker import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner +import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag @@ -66,7 +64,6 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.about.AppUpdateDialog -import org.koitharu.kotatsu.settings.onboard.OnboardDialogFragment import javax.inject.Inject import com.google.android.material.R as materialR @@ -98,17 +95,6 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav super.onCreate(savedInstanceState) setContentView(ActivityMainBinding.inflate(layoutInflater)) - if (bottomNav != null) { - ViewCompat.setOnApplyWindowInsetsListener(viewBinding.root) { _, insets -> - if (insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom > 0) { - val elevation = bottomNav?.elevation ?: 0f - window.setNavigationBarTransparentCompat(this@MainActivity, elevation) - } - insets - } - ViewCompat.requestApplyInsets(viewBinding.root) - } - with(viewBinding.searchView) { onFocusChangeListener = this@MainActivity searchSuggestionListener = this@MainActivity @@ -142,7 +128,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav viewModel.counters.observe(this, ::onCountersChanged) viewModel.appUpdate.observe(this, MenuInvalidator(this)) viewModel.onFirstStart.observeEvent(this) { - OnboardDialogFragment.show(supportFragmentManager) + WelcomeSheet.show(supportFragmentManager) } viewModel.isIncognitoMode.observe(this) { adjustSearchUI(isSearchOpened(), false) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt new file mode 100644 index 000000000..a5f25dcef --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt @@ -0,0 +1,133 @@ +package org.koitharu.kotatsu.main.ui.welcome + +import android.accounts.AccountManager +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.isGone +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import com.google.android.material.chip.Chip +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.titleResId +import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet +import org.koitharu.kotatsu.core.ui.widgets.ChipsView +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.showDistinct +import org.koitharu.kotatsu.core.util.ext.tryLaunch +import org.koitharu.kotatsu.databinding.SheetWelcomeBinding +import org.koitharu.kotatsu.filter.ui.model.FilterProperty +import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.util.toTitleCase +import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment +import java.util.Locale + +@AndroidEntryPoint +class WelcomeSheet : BaseAdaptiveSheet(), ChipsView.OnChipClickListener, View.OnClickListener, + ActivityResultCallback { + + private val viewModel by viewModels() + + private val backupSelectCall = registerForActivityResult( + ActivityResultContracts.OpenDocument(), + this, + ) + + override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetWelcomeBinding { + return SheetWelcomeBinding.inflate(inflater, container, false) + } + + override fun onViewBindingCreated(binding: SheetWelcomeBinding, savedInstanceState: Bundle?) { + super.onViewBindingCreated(binding, savedInstanceState) + binding.textViewWelcomeTitle.isGone = resources.getBoolean(R.bool.is_tablet) + binding.chipsLocales.onChipClickListener = this + binding.chipsType.onChipClickListener = this + binding.chipBackup.setOnClickListener(this) + binding.chipSync.setOnClickListener(this) + + viewModel.locales.observe(viewLifecycleOwner, ::onLocalesChanged) + viewModel.types.observe(viewLifecycleOwner, ::onTypesChanged) + } + + override fun onChipClick(chip: Chip, data: Any?) { + when (data) { + is ContentType -> viewModel.setTypeChecked(data, chip.isChecked) + is Locale? -> viewModel.setLocaleChecked(data, chip.isChecked) + } + } + + override fun onClick(v: View) { + when (v.id) { + R.id.chip_backup -> { + if (!backupSelectCall.tryLaunch(arrayOf("*/*"))) { + Snackbar.make( + v, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, + ).show() + } + } + + R.id.chip_sync -> { + val am = AccountManager.get(v.context) + val accountType = getString(R.string.account_type_sync) + am.addAccount(accountType, accountType, null, null, requireActivity(), null, null) + } + } + } + + override fun onActivityResult(result: Uri?) { + if (result != null) { + RestoreDialogFragment.show(parentFragmentManager, result) + } + } + + private fun onLocalesChanged(value: FilterProperty) { + val chips = viewBinding?.chipsLocales ?: return + chips.setChips( + value.availableItems.map { + ChipsView.ChipModel( + tint = 0, + title = it?.getDisplayLanguage(it)?.toTitleCase(it) ?: getString(R.string.various_languages), + icon = 0, + isCheckable = true, + isChecked = it in value.selectedItems, + data = it, + ) + }, + ) + } + + private fun onTypesChanged(value: FilterProperty) { + val chips = viewBinding?.chipsType ?: return + chips.setChips( + value.availableItems.map { + ChipsView.ChipModel( + tint = 0, + title = getString(it.titleResId), + icon = 0, + isCheckable = true, + isChecked = it in value.selectedItems, + data = it, + ) + }, + ) + } + + companion object { + + private const val TAG = "WelcomeSheet" + + fun show(fm: FragmentManager) = WelcomeSheet().showDistinct(fm, TAG) + + fun dismiss(fm: FragmentManager): Boolean { + val sheet = fm.findFragmentByTag(TAG) as? WelcomeSheet ?: return false + sheet.dismissAllowingStateLoss() + return true + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeViewModel.kt new file mode 100644 index 000000000..311a6d64b --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeViewModel.kt @@ -0,0 +1,107 @@ +package org.koitharu.kotatsu.main.ui.welcome + +import android.content.Context +import androidx.core.os.ConfigurationCompat +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.util.LocaleComparator +import org.koitharu.kotatsu.core.util.ext.sortedWithSafe +import org.koitharu.kotatsu.core.util.ext.toList +import org.koitharu.kotatsu.explore.data.MangaSourcesRepository +import org.koitharu.kotatsu.filter.ui.model.FilterProperty +import org.koitharu.kotatsu.parsers.model.ContentType +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.util.mapToSet +import java.util.EnumSet +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +class WelcomeViewModel @Inject constructor( + private val repository: MangaSourcesRepository, + @ApplicationContext context: Context, +) : BaseViewModel() { + + private val allSources = repository.allMangaSources + private val localesGroups by lazy { allSources.groupBy { it.locale?.let { x -> Locale(x) } } } + + private var updateJob: Job + + val locales = MutableStateFlow( + FilterProperty( + availableItems = listOf(null), + selectedItems = setOf(null), + isLoading = true, + error = null, + ), + ) + + val types = MutableStateFlow( + FilterProperty( + availableItems = ContentType.entries.toList(), + selectedItems = setOf(ContentType.MANGA), + isLoading = false, + error = null, + ), + ) + + init { + updateJob = launchJob(Dispatchers.Default) { + val languages = localesGroups.keys.associateBy { x -> x?.language } + val selectedLocales = HashSet(2) + selectedLocales += ConfigurationCompat.getLocales(context.resources.configuration).toList() + .firstNotNullOfOrNull { lc -> languages[lc.language] } + selectedLocales += null + locales.value = locales.value.copy( + availableItems = localesGroups.keys.sortedWithSafe(nullsFirst(LocaleComparator())), + selectedItems = selectedLocales, + isLoading = false, + ) + } + } + + fun setLocaleChecked(locale: Locale?, isChecked: Boolean) { + val snapshot = locales.value + locales.value = snapshot.copy( + selectedItems = if (isChecked) { + snapshot.selectedItems + locale + } else { + snapshot.selectedItems - locale + }, + ) + val prevJob = updateJob + updateJob = launchJob(Dispatchers.Default) { + prevJob.join() + commit() + } + } + + fun setTypeChecked(type: ContentType, isChecked: Boolean) { + val snapshot = types.value + types.value = snapshot.copy( + selectedItems = if (isChecked) { + snapshot.selectedItems + type + } else { + snapshot.selectedItems - type + }, + ) + val prevJob = updateJob + updateJob = launchJob(Dispatchers.Default) { + prevJob.join() + commit() + } + } + + private suspend fun commit() { + val languages = locales.value.selectedItems.mapToSet { it?.language } + val types = types.value.selectedItems + val enabledSources = allSources.filterTo(EnumSet.noneOf(MangaSource::class.java)) { x -> + x.contentType in types && x.locale in languages + } + repository.setSourcesEnabledExclusive(enabledSources) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ReaderColorFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ReaderColorFilter.kt index 9b4f8a828..eb5c73669 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ReaderColorFilter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/ReaderColorFilter.kt @@ -7,13 +7,17 @@ data class ReaderColorFilter( val brightness: Float, val contrast: Float, val isInverted: Boolean, + val isGrayscale: Boolean, ) { val isEmpty: Boolean - get() = !isInverted && brightness == 0f && contrast == 0f + get() = !isGrayscale && !isInverted && brightness == 0f && contrast == 0f fun toColorFilter(): ColorMatrixColorFilter { val cm = ColorMatrix() + if (isGrayscale) { + cm.grayscale() + } if (isInverted) { cm.inverted() } @@ -49,6 +53,20 @@ data class ReaderColorFilter( 0.0f, 0.0f, -1.0f, 1.0f, 1.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, + ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt index 65aaf9c46..8fb632c59 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt @@ -16,6 +16,7 @@ import coil.ImageLoader import coil.request.ImageRequest import coil.size.Scale import coil.size.ViewSizeResolver +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.slider.LabelFormatter import com.google.android.material.slider.Slider import dagger.hilt.android.AndroidEntryPoint @@ -62,6 +63,7 @@ class ColorFilterConfigActivity : viewBinding.sliderContrast.setLabelFormatter(formatter) viewBinding.sliderBrightness.setLabelFormatter(formatter) viewBinding.switchInvert.setOnCheckedChangeListener(this) + viewBinding.switchGrayscale.setOnCheckedChangeListener(this) viewBinding.buttonDone.setOnClickListener(this) viewBinding.buttonReset.setOnClickListener(this) @@ -84,18 +86,16 @@ class ColorFilterConfigActivity : } } - override fun onCheckedChanged(buttonView: CompoundButton?, isChecked: Boolean) { - viewModel.setInversion(isChecked) + override fun onCheckedChanged(buttonView: CompoundButton, isChecked: Boolean) { + when (buttonView.id) { + R.id.switch_invert -> viewModel.setInversion(isChecked) + R.id.switch_grayscale -> viewModel.setGrayscale(isChecked) + } } override fun onClick(v: View) { when (v.id) { - R.id.button_done -> if (viewBinding.checkBoxGlobal.isChecked) { - viewModel.saveGlobally() - } else { - viewModel.save() - } - + R.id.button_done -> showSaveConfirmation() R.id.button_reset -> viewModel.reset() } } @@ -113,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.save() + }.setNeutralButton(R.string.globally) { _, _ -> + viewModel.saveGlobally() + }.show() + } + private fun onColorFilterChanged(readerColorFilter: ReaderColorFilter?) { viewBinding.sliderBrightness.setValueRounded(readerColorFilter?.brightness ?: 0f) viewBinding.sliderContrast.setValueRounded(readerColorFilter?.contrast ?: 0f) viewBinding.switchInvert.setChecked(readerColorFilter?.isInverted ?: false, false) + viewBinding.switchGrayscale.setChecked(readerColorFilter?.isGrayscale ?: false, false) viewBinding.imageViewAfter.colorFilter = readerColorFilter?.toColorFilter() } @@ -138,6 +151,8 @@ class ColorFilterConfigActivity : private fun onLoadingChanged(isLoading: Boolean) { viewBinding.sliderContrast.isEnabled = !isLoading viewBinding.sliderBrightness.isEnabled = !isLoading + viewBinding.switchInvert.isEnabled = !isLoading + viewBinding.switchGrayscale.isEnabled = !isLoading viewBinding.buttonDone.isEnabled = !isLoading } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigBackPressedDispatcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigBackPressedDispatcher.kt index 97527946c..7a7a5480a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigBackPressedDispatcher.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigBackPressedDispatcher.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.reader.ui.colorfilter -import android.content.Context import android.content.DialogInterface import androidx.activity.OnBackPressedCallback import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -8,7 +7,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.call class ColorFilterConfigBackPressedDispatcher( - private val context: Context, + private val activity: ColorFilterConfigActivity, private val viewModel: ColorFilterConfigViewModel, ) : OnBackPressedCallback(true), DialogInterface.OnClickListener { @@ -24,12 +23,12 @@ class ColorFilterConfigBackPressedDispatcher( when (which) { DialogInterface.BUTTON_NEGATIVE -> viewModel.onDismiss.call(Unit) DialogInterface.BUTTON_NEUTRAL -> dialog.dismiss() - DialogInterface.BUTTON_POSITIVE -> viewModel.save() + DialogInterface.BUTTON_POSITIVE -> activity.showSaveConfirmation() } } private fun showConfirmation() { - MaterialAlertDialogBuilder(context) + MaterialAlertDialogBuilder(activity) .setTitle(R.string.color_correction) .setMessage(R.string.text_unsaved_changes_prompt) .setNegativeButton(R.string.discard, this) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt index e41e19045..0d38b4738 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt @@ -44,33 +44,19 @@ class ColorFilterConfigViewModel @Inject constructor( } fun setBrightness(brightness: Float) { - val cf = colorFilter.value - colorFilter.value = ReaderColorFilter( - brightness = brightness, - contrast = cf?.contrast ?: 0f, - isInverted = cf?.isInverted ?: false, - ).takeUnless { it.isEmpty } + updateColorFilter { it.copy(brightness = brightness) } } fun setContrast(contrast: Float) { - val cf = colorFilter.value - colorFilter.value = ReaderColorFilter( - brightness = cf?.brightness ?: 0f, - contrast = contrast, - isInverted = cf?.isInverted ?: false, - ).takeUnless { it.isEmpty } + updateColorFilter { it.copy(contrast = contrast) } } fun setInversion(invert: Boolean) { - val cf = colorFilter.value - if (invert == cf?.isInverted) { - return - } - colorFilter.value = ReaderColorFilter( - brightness = cf?.brightness ?: 0f, - contrast = cf?.contrast ?: 0f, - isInverted = invert, - ).takeUnless { it.isEmpty } + updateColorFilter { it.copy(isInverted = invert) } + } + + fun setGrayscale(grayscale: Boolean) { + updateColorFilter { it.copy(isGrayscale = grayscale) } } fun reset() { @@ -85,7 +71,18 @@ class ColorFilterConfigViewModel @Inject constructor( } fun saveGlobally() { - settings.readerColorFilter = colorFilter.value - onDismiss.call(Unit) + 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 } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt index 0cc10934e..60708cfcc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt @@ -102,10 +102,10 @@ class ReaderSettings( AppSettings.KEY_READER_BACKGROUND, AppSettings.KEY_32BIT_COLOR, AppSettings.KEY_READER_OPTIMIZE, - AppSettings.KEY_CF_ENABLED, AppSettings.KEY_CF_CONTRAST, AppSettings.KEY_CF_BRIGHTNESS, AppSettings.KEY_CF_INVERTED, + AppSettings.KEY_CF_GRAYSCALE, ) override suspend fun emit(value: ReaderColorFilter?) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt index d7e208bda..2c4827207 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BasePageHolder.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.annotation.CallSuper import androidx.lifecycle.LifecycleOwner import androidx.viewbinding.ViewBinding +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.os.NetworkState import org.koitharu.kotatsu.core.ui.list.lifecycle.LifecycleAwareViewHolder @@ -69,9 +70,11 @@ abstract class BasePageHolder( delegate.onRecycle() } - protected fun getBackgroundDownsampling() = when { - !settings.isReaderOptimizationEnabled -> 1 - context.isLowRamDevice() -> 8 - else -> 4 + protected fun SubsamplingScaleImageView.applyDownsampling(isForeground: Boolean) { + downsampling = when { + isForeground || !settings.isReaderOptimizationEnabled -> 1 + context.isLowRamDevice() -> 8 + else -> 4 + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt index 9c544deea..1d7e1d40a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.plus +import kotlinx.coroutines.withContext import kotlinx.coroutines.yield import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.os.NetworkState @@ -158,7 +159,9 @@ class PageHolderDelegate( callback.onLoadingStarted() yield() try { - val task = loader.loadPageAsync(data, force) + val task = withContext(Dispatchers.Default) { + loader.loadPageAsync(data, force) + } uri = coroutineScope { val progressObserver = observeProgress(this, task.progressAsFlow()) val file = task.await() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt index 29ffe1bea..e70d7b950 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PageHolder.kt @@ -47,12 +47,12 @@ open class PageHolder( override fun onResume() { super.onResume() - binding.ssiv.downsampling = 1 + binding.ssiv.applyDownsampling(isForeground = true) } override fun onPause() { super.onPause() - binding.ssiv.downsampling = getBackgroundDownsampling() + binding.ssiv.applyDownsampling(isForeground = false) } override fun onConfigChanged() { @@ -60,7 +60,7 @@ open class PageHolder( if (settings.applyBitmapConfig(binding.ssiv)) { delegate.reload() } - binding.ssiv.downsampling = if (isResumed()) 1 else getBackgroundDownsampling() + binding.ssiv.applyDownsampling(isResumed()) } @SuppressLint("SetTextI18n") diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PagerPaginationListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PagerPaginationListener.kt deleted file mode 100644 index 1f9c40cbf..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/standard/PagerPaginationListener.kt +++ /dev/null @@ -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() - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/ListPaginationListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/ListPaginationListener.kt deleted file mode 100644 index a3879a762..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/ListPaginationListener.kt +++ /dev/null @@ -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() - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonFrameLayout.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonFrameLayout.kt index 0544f741b..719b4a790 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonFrameLayout.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonFrameLayout.kt @@ -12,9 +12,11 @@ class WebtoonFrameLayout @JvmOverloads constructor( @AttrRes defStyleAttr: Int = 0, ) : FrameLayout(context, attrs, defStyleAttr) { - val target: WebtoonImageView by lazy(LazyThreadSafetyMode.NONE) { - findViewById(R.id.ssiv) - } + private var _target: WebtoonImageView? = null + val target: WebtoonImageView + get() = _target ?: findViewById(R.id.ssiv).also { + _target = it + } fun dispatchVerticalScroll(dy: Int): Int { if (dy == 0) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt index 6fc1d8432..48111be7b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonHolder.kt @@ -5,7 +5,6 @@ import android.view.View import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner import com.davemorrissey.labs.subscaleview.ImageSource -import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.os.NetworkState @@ -38,22 +37,22 @@ class WebtoonHolder( bindingInfo.buttonErrorDetails.setOnClickListener(this) } - override fun onConfigChanged() { - super.onConfigChanged() - if (settings.applyBitmapConfig(binding.ssiv)) { - delegate.reload() - } - // FIXME binding.ssiv.downsampling = if (isResumed()) 1 else getBackgroundDownsampling() - } - override fun onResume() { super.onResume() - binding.ssiv.downsampling = 1 + binding.ssiv.applyDownsampling(isForeground = true) } override fun onPause() { super.onPause() - // FIXME binding.ssiv.downsampling = getBackgroundDownsampling() + binding.ssiv.applyDownsampling(isForeground = false) + } + + override fun onConfigChanged() { + super.onConfigChanged() + if (settings.applyBitmapConfig(binding.ssiv)) { + delegate.reload() + } + binding.ssiv.applyDownsampling(isResumed()) } override fun onBind(data: ReaderPage) { @@ -97,9 +96,6 @@ class WebtoonHolder( override fun onImageShowing(settings: ReaderSettings) { binding.ssiv.colorFilter = settings.colorFilter?.toColorFilter() with(binding.ssiv) { - minimumScaleType = SubsamplingScaleImageView.SCALE_TYPE_CUSTOM - minScale = width / sWidth.toFloat() - maxScale = minScale scrollTo( when { scrollToRestore != 0 -> scrollToRestore diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt index d663fa2da..b4a2de35c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt @@ -1,14 +1,16 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint import android.graphics.PointF import android.util.AttributeSet import androidx.core.view.ancestors import androidx.recyclerview.widget.RecyclerView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView -import org.koitharu.kotatsu.parsers.util.toIntUp - -private const val SCROLL_UNKNOWN = -1 +import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.core.util.ext.resolveDp +import kotlin.math.roundToInt class WebtoonImageView @JvmOverloads constructor( context: Context, @@ -18,7 +20,14 @@ class WebtoonImageView @JvmOverloads constructor( private val ct = PointF() private var scrollPos = 0 - private var scrollRange = SCROLL_UNKNOWN + private var debugPaint: Paint? = null + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + if (BuildConfig.DEBUG) { + drawDebug(canvas) + } + } fun scrollBy(delta: Int) { val maxScroll = getScrollRange() @@ -41,14 +50,14 @@ class WebtoonImageView @JvmOverloads constructor( fun getScroll() = scrollPos fun getScrollRange(): Int { - if (scrollRange == SCROLL_UNKNOWN) { - computeScrollRange() + if (!isReady) { + return 0 } - return scrollRange.coerceAtLeast(0) + val totalHeight = (sHeight * width / sWidth.toFloat()).roundToInt() + return (totalHeight - height).coerceAtLeast(0) } override fun recycle() { - scrollRange = SCROLL_UNKNOWN scrollPos = 0 super.recycle() } @@ -88,33 +97,54 @@ class WebtoonImageView @JvmOverloads constructor( setMeasuredDimension(width, height) } + override fun onDownsamplingChanged() { + super.onDownsamplingChanged() + adjustScale() + } + + override fun onReady() { + super.onReady() + adjustScale() + } + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) - if (oldh == h || oldw == 0 || oldh == 0 || scrollRange == SCROLL_UNKNOWN) return - - computeScrollRange() - val container = ancestors.firstNotNullOfOrNull { it as? WebtoonFrameLayout } ?: return - val parentHeight = parentHeight() - if (scrollPos != 0 && container.bottom < parentHeight) { - scrollTo(scrollRange) + if (oldh != h && oldw != 0 && oldh != 0 && isReady) { + ancestors.firstNotNullOfOrNull { it as? WebtoonRecyclerView }?.updateChildrenScroll() + } else { + return } } private fun scrollToInternal(pos: Int) { + minScale = width / sWidth.toFloat() + maxScale = minScale scrollPos = pos ct.set(sWidth / 2f, (height / 2f + pos.toFloat()) / minScale) setScaleAndCenter(minScale, ct) } - private fun computeScrollRange() { - if (!isReady) { - return - } - val totalHeight = (sHeight * minScale).toIntUp() - scrollRange = (totalHeight - height).coerceAtLeast(0) + private fun adjustScale() { + minScale = width / sWidth.toFloat() + maxScale = minScale + minimumScaleType = SCALE_TYPE_CUSTOM } private fun parentHeight(): Int { return ancestors.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0 } + + private fun drawDebug(canvas: Canvas) { + val paint = debugPaint ?: Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = android.graphics.Color.RED + strokeWidth = context.resources.resolveDp(2f) + textAlign = android.graphics.Paint.Align.LEFT + textSize = context.resources.resolveDp(14f) + debugPaint = this + } + paint.style = Paint.Style.STROKE + canvas.drawRect(1f, 1f, width.toFloat() - 1f, height.toFloat() - 1f, paint) + paint.style = Paint.Style.FILL + canvas.drawText("${getScroll()} / ${getScrollRange()}", 100f, 100f, paint) + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonRecyclerView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonRecyclerView.kt index a8c0ff2e4..788a11107 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonRecyclerView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonRecyclerView.kt @@ -5,6 +5,7 @@ import android.util.AttributeSet import android.view.View import androidx.core.view.ViewCompat.TYPE_TOUCH import androidx.core.view.forEach +import androidx.core.view.iterator import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition import java.util.LinkedList @@ -16,6 +17,7 @@ class WebtoonRecyclerView @JvmOverloads constructor( private var onPageScrollListeners: MutableList? = null private val detachedViews = WeakHashMap() + private var isFixingScroll: Boolean = false override fun onChildDetachedFromWindow(child: View) { super.onChildDetachedFromWindow(child) @@ -54,6 +56,13 @@ class WebtoonRecyclerView @JvmOverloads constructor( return consumedY != 0 || dy == 0 } + override fun onScrollStateChanged(state: Int) { + super.onScrollStateChanged(state) + if (state == SCROLL_STATE_IDLE) { + updateChildrenScroll() + } + } + private fun consumeVerticalScroll(dy: Int): Int { if (childCount == 0) { return 0 @@ -121,6 +130,38 @@ class WebtoonRecyclerView @JvmOverloads constructor( } } + fun updateChildrenScroll() { + if (isFixingScroll) { + return + } + isFixingScroll = true + for (child in this) { + val ssiv = (child as WebtoonFrameLayout).target + if (adjustScroll(child, ssiv)) { + break + } + } + isFixingScroll = false + } + + private fun adjustScroll(child: View, ssiv: WebtoonImageView): Boolean = when { + child.bottom < height && ssiv.getScroll() < ssiv.getScrollRange() -> { + val distance = minOf(height - child.bottom, ssiv.getScrollRange() - ssiv.getScroll()) + scrollBy(0, -distance) + ssiv.scrollBy(distance) + true + } + + child.top > 0 && ssiv.getScroll() > 0 -> { + val distance = minOf(child.top, ssiv.getScroll()) + scrollBy(0, distance) + ssiv.scrollBy(-distance) + true + } + + else -> false + } + abstract class OnPageScrollListener { private var lastPosition = NO_POSITION diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/TargetScrollObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/TargetScrollObserver.kt deleted file mode 100644 index bc27e4a01..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/thumbnails/adapter/TargetScrollObserver.kt +++ /dev/null @@ -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 - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt index 008dadcb2..90a7bb5aa 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt @@ -21,8 +21,8 @@ import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.details.ui.DetailsActivity 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.sheet.FilterSheetFragment import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.MangaSource diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index 35b01a548..be03efa3b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -78,7 +78,7 @@ open class RemoteListViewModel @Inject constructor( when { list.isNullOrEmpty() && error != null -> add(error.toErrorState(canRetry = true)) list == null -> add(LoadingState) - list.isEmpty() -> add(createEmptyState(header.value.hasSelectedTags)) + list.isEmpty() -> add(createEmptyState(canResetFilter = header.value.isFilterApplied)) else -> { list.toUi(this, mode, listExtraProvider) when { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/AniListAuthenticator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/AniListAuthenticator.kt index 4def5e1d6..01c909456 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/AniListAuthenticator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/anilist/data/AniListAuthenticator.kt @@ -5,7 +5,6 @@ import okhttp3.Authenticator import okhttp3.Request import okhttp3.Response import okhttp3.Route -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt index 844e1a815..54c28c99f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt @@ -118,7 +118,7 @@ class ScrobblingSelectorViewModel @Inject constructor( if (!append) { scrobblerMangaList.value = list } else if (list.isNotEmpty()) { - scrobblerMangaList.value = scrobblerMangaList.value + list + scrobblerMangaList.value += list } hasNextPage.value = list.isNotEmpty() }.onFailure { error -> diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/data/MALAuthenticator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/data/MALAuthenticator.kt index 5a343bf9b..a365e4d09 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/data/MALAuthenticator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/mal/data/MALAuthenticator.kt @@ -5,7 +5,6 @@ import okhttp3.Authenticator import okhttp3.Request import okhttp3.Response import okhttp3.Route -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt index d67dd81d8..a7dc0b77d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/shikimori/data/ShikimoriAuthenticator.kt @@ -5,7 +5,6 @@ import okhttp3.Authenticator import okhttp3.Request import okhttp3.Response import okhttp3.Route -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.scrobbling.common.data.ScrobblerStorage diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt index 9c7511751..6a18aa976 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt @@ -25,6 +25,7 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaTags import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.model.titleRes +import org.koitharu.kotatsu.core.util.ViewBadge import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.observe @@ -32,8 +33,8 @@ import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.databinding.ActivityMangaListBinding import org.koitharu.kotatsu.filter.ui.FilterHeaderFragment 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.sheet.FilterSheetFragment import org.koitharu.kotatsu.list.ui.preview.PreviewFragment import org.koitharu.kotatsu.local.ui.LocalListFragment import org.koitharu.kotatsu.main.ui.owners.AppBarOwner @@ -146,8 +147,11 @@ class MangaListActivity : val filter = filterOwner.filter val chipSort = viewBinding.buttonOrder if (chipSort != null) { + val filterBadge = ViewBadge(chipSort, this) + filterBadge.setMaxCharacterCount(0) filter.header.observe(this) { chipSort.setTextAndVisible(it.sortOrder?.titleRes ?: 0) + filterBadge.counter = if (it.isFilterApplied) 1 else 0 } } else { filter.header.map { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt index 8ae7a5052..fdf1e1b99 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/MultiSearchViewModel.kt @@ -95,7 +95,7 @@ class MultiSearchViewModel @Inject constructor( } fun retry() { - retryCounter.value = retryCounter.value + 1 + retryCounter.value += 1 } fun download(items: Set) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt index 5b22c96c5..9029d7ef4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionSourceAD.kt @@ -1,11 +1,11 @@ package org.koitharu.kotatsu.search.ui.suggestion.adapter -import androidx.core.text.buildSpannedString import androidx.lifecycle.LifecycleOwner import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.getSummary +import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.ui.image.FaviconDrawable import org.koitharu.kotatsu.core.util.ext.enqueueWith @@ -14,7 +14,6 @@ import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemSearchSuggestionSourceBinding import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem -import org.koitharu.kotatsu.settings.sources.adapter.appendNsfwLabel fun searchSuggestionSourceAD( coil: ImageLoader, @@ -32,15 +31,7 @@ fun searchSuggestionSourceAD( } bind { - binding.textViewTitle.text = if (item.isNsfw) { - buildSpannedString { - append(item.source.title) - append(' ') - appendNsfwLabel(context) - } - } else { - item.source.title - } + binding.textViewTitle.text = item.source.getTitle(context) binding.textViewSubtitle.text = item.source.getSummary(context) binding.switchLocal.isChecked = item.isEnabled val fallbackIcon = FaviconDrawable(context, R.style.FaviconDrawable_Small, item.source.name) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionTagsAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionTagsAD.kt index 7360de65c..633592471 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionTagsAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionTagsAD.kt @@ -1,23 +1,23 @@ package org.koitharu.kotatsu.search.ui.suggestion.adapter -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate -import org.koitharu.kotatsu.R +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.ui.widgets.ChipsView +import org.koitharu.kotatsu.databinding.ItemSearchSuggestionTagsBinding import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem fun searchSuggestionTagsAD( listener: SearchSuggestionListener, -) = adapterDelegate(R.layout.item_search_suggestion_tags) { +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemSearchSuggestionTagsBinding.inflate(layoutInflater, parent, false) }, +) { - val chipGroup = itemView as ChipsView - - chipGroup.onChipClickListener = ChipsView.OnChipClickListener { _, data -> + binding.chipsGenres.onChipClickListener = ChipsView.OnChipClickListener { _, data -> listener.onTagClick(data as? MangaTag ?: return@OnChipClickListener) } bind { - chipGroup.setChips(item.tags) + binding.chipsGenres.setChips(item.tags) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchBehavior.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchBehavior.kt deleted file mode 100644 index e13a694de..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchBehavior.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.koitharu.kotatsu.search.ui.widget - -import android.content.Context -import android.util.AttributeSet -import android.view.View -import android.widget.LinearLayout -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.view.ViewCompat -import com.google.android.material.appbar.AppBarLayout -import com.google.android.material.bottomnavigation.BottomNavigationView - -class SearchBehavior(context: Context?, attrs: AttributeSet?) : - CoordinatorLayout.Behavior(context, attrs) { - - override fun layoutDependsOn( - parent: CoordinatorLayout, - child: SearchToolbar, - dependency: View, - ): Boolean { - return when (dependency) { - is AppBarLayout -> true - is LinearLayout, is BottomNavigationView -> { - dependency.z = child.z + 1 - true - } - else -> super.layoutDependsOn(parent, child, dependency) - } - } - - override fun onDependentViewChanged( - parent: CoordinatorLayout, - child: SearchToolbar, - dependency: View, - ): Boolean { - if (dependency is AppBarLayout) { - child.translationY = dependency.getY() - return true - } - return super.onDependentViewChanged(parent, child, dependency) - } - - override fun onStartNestedScroll( - coordinatorLayout: CoordinatorLayout, - child: SearchToolbar, - directTargetChild: View, - target: View, - axes: Int, - type: Int, - ): Boolean { - return axes == ViewCompat.SCROLL_AXIS_VERTICAL - } - -} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt index 7572b3965..5769cb73c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt @@ -17,6 +17,7 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity +import org.koitharu.kotatsu.settings.utils.DozeHelper import javax.inject.Inject @AndroidEntryPoint @@ -24,6 +25,8 @@ class DownloadsSettingsFragment : BasePreferenceFragment(R.string.downloads), SharedPreferences.OnSharedPreferenceChangeListener { + private val dozeHelper = DozeHelper(this) + @Inject lateinit var storageManager: LocalStorageManager @@ -32,6 +35,7 @@ class DownloadsSettingsFragment : override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_downloads) + dozeHelper.updatePreference() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -74,6 +78,10 @@ class DownloadsSettingsFragment : true } + AppSettings.KEY_IGNORE_DOZE -> { + dozeHelper.startIgnoreDoseActivity() + } + else -> super.onPreferenceTreeClick(preference) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt index 3822f0da3..2de76928c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt @@ -54,7 +54,11 @@ class AppBackupAgent : BackupAgent() { mtime: Long ) { if (destination?.name?.endsWith(".bk.zip") == true) { - restoreBackupFile(data.fileDescriptor, size, BackupRepository(MangaDatabase(applicationContext), AppSettings(applicationContext))) + restoreBackupFile( + data.fileDescriptor, + size, + BackupRepository(MangaDatabase(applicationContext), AppSettings(applicationContext)), + ) destination.delete() } else { super.onRestoreFile(data, size, destination, type, mode, mtime) @@ -69,6 +73,7 @@ class AppBackupAgent : BackupAgent() { backup.put(repository.dumpCategories()) backup.put(repository.dumpFavourites()) backup.put(repository.dumpBookmarks()) + backup.put(repository.dumpSources()) backup.put(repository.dumpSettings()) backup.finish() backup.file @@ -86,11 +91,12 @@ class AppBackupAgent : BackupAgent() { val backup = BackupZipInput(tempFile) try { runBlocking { - backup.getEntry(BackupEntry.HISTORY)?.let { repository.restoreHistory(it) } - backup.getEntry(BackupEntry.CATEGORIES)?.let { repository.restoreCategories(it) } - backup.getEntry(BackupEntry.FAVOURITES)?.let { repository.restoreFavourites(it) } - backup.getEntry(BackupEntry.BOOKMARKS)?.let { repository.restoreBookmarks(it) } - backup.getEntry(BackupEntry.SETTINGS)?.let { repository.restoreSettings(it) } + backup.getEntry(BackupEntry.Name.HISTORY)?.let { repository.restoreHistory(it) } + backup.getEntry(BackupEntry.Name.CATEGORIES)?.let { repository.restoreCategories(it) } + backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { repository.restoreFavourites(it) } + backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let { repository.restoreBookmarks(it) } + backup.getEntry(BackupEntry.Name.SOURCES)?.let { repository.restoreSources(it) } + backup.getEntry(BackupEntry.Name.SETTINGS)?.let { repository.restoreSettings(it) } } } finally { backup.close() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntriesAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntriesAdapter.kt new file mode 100644 index 000000000..44ba6a831 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntriesAdapter.kt @@ -0,0 +1,37 @@ +package org.koitharu.kotatsu.settings.backup + +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.util.ext.setChecked +import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding +import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_CHECKED_CHANGED +import org.koitharu.kotatsu.list.ui.adapter.ListItemType + +class BackupEntriesAdapter( + clickListener: OnListItemClickListener, +) : BaseListAdapter() { + + init { + addDelegate(ListItemType.NAV_ITEM, backupEntryAD(clickListener)) + } +} + +private fun backupEntryAD( + clickListener: OnListItemClickListener, +) = adapterDelegateViewBinding( + { layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) }, +) { + + binding.root.setOnClickListener { v -> + clickListener.onItemClick(item, v) + } + + bind { payloads -> + with(binding.root) { + setText(item.titleResId) + setChecked(item.isChecked, PAYLOAD_CHECKED_CHANGED in payloads) + isEnabled = item.isEnabled + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntryModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntryModel.kt new file mode 100644 index 000000000..632807dec --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntryModel.kt @@ -0,0 +1,43 @@ +package org.koitharu.kotatsu.settings.backup + +import androidx.annotation.StringRes +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.backup.BackupEntry +import org.koitharu.kotatsu.list.ui.ListModelDiffCallback +import org.koitharu.kotatsu.list.ui.model.ListModel + +data class BackupEntryModel( + val name: BackupEntry.Name, + val isChecked: Boolean, + val isEnabled: Boolean, +) : ListModel { + + @get:StringRes + val titleResId: Int + get() = when (name) { + BackupEntry.Name.INDEX -> 0 // should not appear here + BackupEntry.Name.HISTORY -> R.string.history + BackupEntry.Name.CATEGORIES -> R.string.favourites_categories + BackupEntry.Name.FAVOURITES -> R.string.favourites + BackupEntry.Name.SETTINGS -> R.string.settings + BackupEntry.Name.BOOKMARKS -> R.string.bookmarks + BackupEntry.Name.SOURCES -> R.string.remote_sources + } + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is BackupEntryModel && other.name == name + } + + override fun getChangePayload(previousState: ListModel): Any? { + if (previousState !is BackupEntryModel) { + return null + } + return if (previousState.isEnabled != isEnabled) { + ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED + } else if (previousState.isChecked != isChecked) { + ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED + } else { + super.getChangePayload(previousState) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt index 90933985f..654e1656b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt @@ -24,21 +24,25 @@ class BackupViewModel @Inject constructor( init { launchLoadingJob { val file = BackupZipOutput(context).use { backup -> + val step = 1f / 6f backup.put(repository.createIndex()) progress.value = 0f backup.put(repository.dumpHistory()) - progress.value = 0.2f + progress.value += step backup.put(repository.dumpCategories()) - progress.value = 0.4f + progress.value += step backup.put(repository.dumpFavourites()) - progress.value = 0.6f + progress.value += step backup.put(repository.dumpBookmarks()) - progress.value = 0.8f + progress.value += step + backup.put(repository.dumpSources()) + + progress.value += step backup.put(repository.dumpSettings()) backup.finish() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt index 3c7f5ced6..224df7b7b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt @@ -42,6 +42,7 @@ class PeriodicalBackupWorker @AssistedInject constructor( backup.put(repository.dumpCategories()) backup.put(repository.dumpFavourites()) backup.put(repository.dumpBookmarks()) + backup.put(repository.dumpSources()) backup.put(repository.dumpSettings()) backup.finish() backup.file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt index 903b1ad30..d1515e841 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt @@ -3,40 +3,58 @@ package org.koitharu.kotatsu.settings.backup import android.net.Uri import android.os.Bundle import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup +import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.combine import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.backup.CompositeResult import org.koitharu.kotatsu.core.ui.AlertDialogFragment +import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.withArgs -import org.koitharu.kotatsu.databinding.DialogProgressBinding +import org.koitharu.kotatsu.databinding.DialogRestoreBinding +import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.Date import kotlin.math.roundToInt @AndroidEntryPoint -class RestoreDialogFragment : AlertDialogFragment() { +class RestoreDialogFragment : AlertDialogFragment(), OnListItemClickListener, + View.OnClickListener { private val viewModel: RestoreViewModel by viewModels() override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, - ) = DialogProgressBinding.inflate(inflater, container, false) + ) = DialogRestoreBinding.inflate(inflater, container, false) - override fun onViewBindingCreated(binding: DialogProgressBinding, savedInstanceState: Bundle?) { + override fun onViewBindingCreated(binding: DialogRestoreBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) - binding.textViewTitle.setText(R.string.restore_backup) - binding.textViewSubtitle.setText(R.string.preparing_) - + val adapter = BackupEntriesAdapter(this) + binding.recyclerView.adapter = adapter + binding.buttonCancel.setOnClickListener(this) + binding.buttonRestore.setOnClickListener(this) + viewModel.availableEntries.observe(viewLifecycleOwner, adapter) viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged) viewModel.onRestoreDone.observeEvent(viewLifecycleOwner, this::onRestoreDone) viewModel.onError.observeEvent(viewLifecycleOwner, this::onError) + combine( + viewModel.isLoading, + viewModel.availableEntries, + viewModel.backupDate, + ::Triple, + ).observe(viewLifecycleOwner, this::onLoadingChanged) } override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { @@ -44,6 +62,40 @@ class RestoreDialogFragment : AlertDialogFragment() { .setCancelable(false) } + override fun onClick(v: View) { + when (v.id) { + R.id.button_cancel -> dismiss() + R.id.button_restore -> viewModel.restore() + } + } + + override fun onItemClick(item: BackupEntryModel, view: View) { + viewModel.onItemClick(item) + } + + private fun onLoadingChanged(value: Triple, Date?>) { + val (isLoading, entries, backupDate) = value + val hasEntries = entries.isNotEmpty() + with(requireViewBinding()) { + progressBar.isVisible = isLoading + recyclerView.isGone = isLoading + textViewSubtitle.textAndVisible = + when { + !isLoading -> backupDate?.formatBackupDate() + hasEntries -> getString(R.string.processing_) + else -> getString(R.string.loading_) + } + buttonRestore.isEnabled = !isLoading && entries.any { it.isChecked } + } + } + + private fun Date.formatBackupDate(): String { + return getString( + R.string.backup_date_, + SimpleDateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(this), + ) + } + private fun onError(e: Throwable) { MaterialAlertDialogBuilder(context ?: return) .setNegativeButton(R.string.close, null) @@ -89,6 +141,9 @@ class RestoreDialogFragment : AlertDialogFragment() { } builder.setPositiveButton(android.R.string.ok, null) .show() + if (!result.isEmpty && !result.isAllFailed) { + WelcomeSheet.dismiss(parentFragmentManager) + } dismiss() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt index fa0b1279b..d1497f696 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt @@ -15,8 +15,12 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.toUriOrNull +import org.koitharu.kotatsu.parsers.util.SuspendLazy import java.io.File import java.io.FileNotFoundException +import java.util.Date +import java.util.EnumMap +import java.util.EnumSet import javax.inject.Inject @HiltViewModel @@ -26,57 +30,127 @@ class RestoreViewModel @Inject constructor( @ApplicationContext context: Context, ) : BaseViewModel() { + private val backupInput = SuspendLazy { + val uri = savedStateHandle.get(RestoreDialogFragment.ARG_FILE) + ?.toUriOrNull() ?: throw FileNotFoundException() + val contentResolver = context.contentResolver + runInterruptible(Dispatchers.IO) { + val tempFile = File.createTempFile("backup_", ".tmp") + (contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + BackupZipInput(tempFile) + } + } + val progress = MutableStateFlow(-1f) val onRestoreDone = MutableEventFlow() + val availableEntries = MutableStateFlow>(emptyList()) + val backupDate = MutableStateFlow(null) + init { - launchLoadingJob { - val uri = savedStateHandle.get(RestoreDialogFragment.ARG_FILE) - ?.toUriOrNull() ?: throw FileNotFoundException() - val contentResolver = context.contentResolver - - val backup = runInterruptible(Dispatchers.IO) { - val tempFile = File.createTempFile("backup_", ".tmp") - (contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input -> - tempFile.outputStream().use { output -> - input.copyTo(output) - } + launchLoadingJob(Dispatchers.Default) { + val backup = backupInput.get() + val entries = backup.entries() + availableEntries.value = BackupEntry.Name.entries.mapNotNull { entry -> + if (entry == BackupEntry.Name.INDEX || entry !in entries) { + return@mapNotNull null } - BackupZipInput(tempFile) + BackupEntryModel( + name = entry, + isChecked = true, + isEnabled = true, + ) + } + backupDate.value = repository.getBackupDate(backup.getEntry(BackupEntry.Name.INDEX)) + } + } + + override fun onCleared() { + super.onCleared() + backupInput.peek()?.cleanupAsync() + } + + fun onItemClick(item: BackupEntryModel) { + val map = availableEntries.value.associateByTo(EnumMap(BackupEntry.Name::class.java)) { it.name } + map[item.name] = item.copy(isChecked = !item.isChecked) + map.validate() + availableEntries.value = map.values.sortedBy { it.name.ordinal } + } + + fun restore() { + launchLoadingJob { + val backup = backupInput.get() + val checkedItems = availableEntries.value.mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { + if (it.isChecked) it.name else null } - try { - val result = CompositeResult() + val result = CompositeResult() + val step = 1f / 6f - progress.value = 0f - backup.getEntry(BackupEntry.HISTORY)?.let { + progress.value = 0f + if (BackupEntry.Name.HISTORY in checkedItems) { + backup.getEntry(BackupEntry.Name.HISTORY)?.let { result += repository.restoreHistory(it) } + } - progress.value = 0.2f - backup.getEntry(BackupEntry.CATEGORIES)?.let { + progress.value += step + if (BackupEntry.Name.CATEGORIES in checkedItems) { + backup.getEntry(BackupEntry.Name.CATEGORIES)?.let { result += repository.restoreCategories(it) } + } - progress.value = 0.4f - backup.getEntry(BackupEntry.FAVOURITES)?.let { + progress.value += step + if (BackupEntry.Name.FAVOURITES in checkedItems) { + backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { result += repository.restoreFavourites(it) } + } - progress.value = 0.6f - backup.getEntry(BackupEntry.BOOKMARKS)?.let { + progress.value += step + if (BackupEntry.Name.BOOKMARKS in checkedItems) { + backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let { result += repository.restoreBookmarks(it) } + } + + progress.value += step + if (BackupEntry.Name.SOURCES in checkedItems) { + backup.getEntry(BackupEntry.Name.SOURCES)?.let { + result += repository.restoreSources(it) + } + } - progress.value = 0.8f - backup.getEntry(BackupEntry.SETTINGS)?.let { + progress.value += step + if (BackupEntry.Name.SETTINGS in checkedItems) { + backup.getEntry(BackupEntry.Name.SETTINGS)?.let { result += repository.restoreSettings(it) } + } - progress.value = 1f - onRestoreDone.call(result) - } finally { - backup.close() - backup.file.delete() + progress.value = 1f + onRestoreDone.call(result) + } + } + + /** + * Check for inconsistent user selection + * Favorites cannot be restored without categories + */ + private fun MutableMap.validate() { + val favorites = this[BackupEntry.Name.FAVOURITES] ?: return + val categories = this[BackupEntry.Name.CATEGORIES] + if (categories?.isChecked == true) { + if (!favorites.isEnabled) { + this[BackupEntry.Name.FAVOURITES] = favorites.copy(isEnabled = true) + } + } else { + if (favorites.isEnabled) { + this[BackupEntry.Name.FAVOURITES] = favorites.copy(isEnabled = false, isChecked = false) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt deleted file mode 100644 index 63b8bf64e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardDialogFragment.kt +++ /dev/null @@ -1,64 +0,0 @@ -package org.koitharu.kotatsu.settings.onboard - -import android.content.DialogInterface -import android.os.Bundle -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.viewModels -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.AlertDialogFragment -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.showAllowStateLoss -import org.koitharu.kotatsu.databinding.DialogOnboardBinding -import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocaleListener -import org.koitharu.kotatsu.settings.onboard.adapter.SourceLocalesAdapter -import org.koitharu.kotatsu.settings.onboard.model.SourceLocale - -@AndroidEntryPoint -class OnboardDialogFragment : - AlertDialogFragment(), - DialogInterface.OnClickListener, SourceLocaleListener { - - private val viewModel by viewModels() - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ) = DialogOnboardBinding.inflate(inflater, container, false) - - override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { - super.onBuildDialog(builder) - .setPositiveButton(R.string.done, this) - .setCancelable(false) - builder.setTitle(R.string.welcome) - return builder - } - - override fun onViewBindingCreated(binding: DialogOnboardBinding, savedInstanceState: Bundle?) { - super.onViewBindingCreated(binding, savedInstanceState) - val adapter = SourceLocalesAdapter(this) - binding.recyclerView.adapter = adapter - binding.textViewTitle.setText(R.string.onboard_text) - viewModel.list.observe(viewLifecycleOwner, adapter) - } - - override fun onItemCheckedChanged(item: SourceLocale, isChecked: Boolean) { - viewModel.setItemChecked(item.key, isChecked) - } - - override fun onClick(dialog: DialogInterface, which: Int) { - when (which) { - DialogInterface.BUTTON_POSITIVE -> dialog.dismiss() - } - } - - companion object { - - private const val TAG = "OnboardDialog" - - fun show(fm: FragmentManager) = OnboardDialogFragment().showAllowStateLoss(fm, TAG) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt deleted file mode 100644 index 9ded73c19..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/OnboardViewModel.kt +++ /dev/null @@ -1,107 +0,0 @@ -package org.koitharu.kotatsu.settings.onboard - -import androidx.core.os.LocaleListCompat -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.ext.map -import org.koitharu.kotatsu.core.util.ext.mapToSet -import org.koitharu.kotatsu.core.util.ext.sortedWithSafe -import org.koitharu.kotatsu.explore.data.MangaSourcesRepository -import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.mapNotNullToSet -import org.koitharu.kotatsu.parsers.util.toTitleCase -import org.koitharu.kotatsu.settings.onboard.model.SourceLocale -import java.util.EnumSet -import java.util.Locale -import javax.inject.Inject - -@HiltViewModel -class OnboardViewModel @Inject constructor( - private val repository: MangaSourcesRepository, -) : BaseViewModel() { - - private val allSources = repository.allMangaSources - private val locales = allSources.groupBy { it.locale } - private val selectedLocales = HashSet() - val list = MutableStateFlow?>(null) - private var updateJob: Job - - init { - updateJob = launchJob(Dispatchers.Default) { - if (repository.isSetupRequired()) { - val deviceLocales = LocaleListCompat.getDefault().mapToSet { x -> - x.language - } - selectedLocales.addAll(deviceLocales) - if (selectedLocales.isEmpty()) { - selectedLocales += "en" - } - selectedLocales += null - } else { - selectedLocales.addAll( - repository.getEnabledSources().mapNotNullToSet { x -> x.locale }, - ) - } - commit() - } - } - - fun setItemChecked(key: String?, isChecked: Boolean) { - val isModified = if (isChecked) { - selectedLocales.add(key) - } else { - selectedLocales.remove(key) - } - if (isModified) { - val prevJob = updateJob - updateJob = launchJob(Dispatchers.Default) { - prevJob.join() - commit() - } - } - } - - private suspend fun commit() { - val enabledSources = allSources.filterTo(EnumSet.noneOf(MangaSource::class.java)) { x -> - x.locale in selectedLocales - } - repository.setSourcesEnabledExclusive(enabledSources) - list.value = locales.map { (key, srcs) -> - val locale = if (key != null) { - Locale(key) - } else null - SourceLocale( - key = key, - title = locale?.getDisplayLanguage(locale)?.toTitleCase(locale), - summary = srcs.joinToString { it.title }, - isChecked = key in selectedLocales, - ) - }.sortedWithSafe(SourceLocaleComparator()) - } - - private class SourceLocaleComparator : Comparator { - - private val deviceLocales = LocaleListCompat.getAdjustedDefault() - .map { it.language } - - override fun compare(a: SourceLocale?, b: SourceLocale?): Int { - return when { - a === b -> 0 - a?.key == null -> 1 - b?.key == null -> -1 - else -> { - val indexA = deviceLocales.indexOf(a.key) - val indexB = deviceLocales.indexOf(b.key) - if (indexA == -1 && indexB == -1) { - compareValues(a.title, b.title) - } else { - -2 - (indexA - indexB) - } - } - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/adapter/SourceLocaleAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/adapter/SourceLocaleAD.kt deleted file mode 100644 index f5ae32b15..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/adapter/SourceLocaleAD.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.koitharu.kotatsu.settings.onboard.adapter - -import android.widget.CompoundButton -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.setChecked -import org.koitharu.kotatsu.core.util.ext.textAndVisible -import org.koitharu.kotatsu.databinding.ItemSourceLocaleBinding -import org.koitharu.kotatsu.settings.onboard.model.SourceLocale - -fun sourceLocaleAD( - listener: SourceLocaleListener, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemSourceLocaleBinding.inflate(inflater, parent, false) }, -) { - - val checkedChangeListener = CompoundButton.OnCheckedChangeListener { _, isChecked -> - listener.onItemCheckedChanged(item, isChecked) - } - binding.switchToggle.setOnCheckedChangeListener(checkedChangeListener) - - bind { payloads -> - binding.textViewTitle.text = item.title ?: getString(R.string.different_languages) - binding.textViewDescription.textAndVisible = item.summary - binding.switchToggle.setOnCheckedChangeListener(null) - binding.switchToggle.setChecked(item.isChecked, payloads.isNotEmpty()) - binding.switchToggle.setOnCheckedChangeListener(checkedChangeListener) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/adapter/SourceLocaleListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/adapter/SourceLocaleListener.kt deleted file mode 100644 index 087849a0c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/adapter/SourceLocaleListener.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.koitharu.kotatsu.settings.onboard.adapter - -import org.koitharu.kotatsu.settings.onboard.model.SourceLocale - -interface SourceLocaleListener { - - fun onItemCheckedChanged(item: SourceLocale, isChecked: Boolean) -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/adapter/SourceLocalesAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/adapter/SourceLocalesAdapter.kt deleted file mode 100644 index a90329b74..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/adapter/SourceLocalesAdapter.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.koitharu.kotatsu.settings.onboard.adapter - -import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.settings.onboard.model.SourceLocale - -class SourceLocalesAdapter( - listener: SourceLocaleListener, -) : BaseListAdapter() { - - init { - delegatesManager.addDelegate(sourceLocaleAD(listener)) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/model/SourceLocale.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/model/SourceLocale.kt deleted file mode 100644 index ff8ea75c3..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/onboard/model/SourceLocale.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.koitharu.kotatsu.settings.onboard.model - -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback -import org.koitharu.kotatsu.list.ui.model.ListModel -import java.util.Locale - -data class SourceLocale( - val key: String?, - val title: String?, - val summary: String?, - val isChecked: Boolean, -) : ListModel, Comparable { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is SourceLocale && key == other.key - } - - override fun getChangePayload(previousState: ListModel): Any? { - return if (previousState is SourceLocale && previousState.isChecked != isChecked) { - ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED - } else { - super.getChangePayload(previousState) - } - } - - override fun compareTo(other: SourceLocale): Int { - return when { - this === other -> 0 - key == Locale.getDefault().language -> -2 - key == null -> 1 - other.key == null -> -1 - else -> compareValues(title, other.title) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt index f6cb40bba..2d8f52c3c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsViewModel.kt @@ -12,11 +12,9 @@ import javax.inject.Inject @HiltViewModel class SourcesSettingsViewModel @Inject constructor( - private val sourcesRepository: MangaSourcesRepository, + sourcesRepository: MangaSourcesRepository, ) : BaseViewModel() { - val totalSourcesCount = sourcesRepository.allMangaSources.size - val enabledSourcesCount = sourcesRepository.observeEnabledSourcesCount() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, -1) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt index 5f7505fad..a27037fa4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapter.kt @@ -2,14 +2,14 @@ package org.koitharu.kotatsu.settings.sources.adapter import androidx.lifecycle.LifecycleOwner import coil.ImageLoader -import org.koitharu.kotatsu.core.ui.BaseListAdapter +import org.koitharu.kotatsu.core.ui.ReorderableListAdapter import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem class SourceConfigAdapter( listener: SourceConfigListener, coil: ImageLoader, lifecycleOwner: LifecycleOwner, -) : BaseListAdapter() { +) : ReorderableListAdapter() { init { with(delegatesManager) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt index 198567789..e8c41887d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt @@ -1,16 +1,8 @@ package org.koitharu.kotatsu.settings.sources.adapter -import android.content.Context -import android.graphics.Color -import android.text.SpannableStringBuilder -import android.text.style.ForegroundColorSpan -import android.text.style.RelativeSizeSpan -import android.text.style.SuperscriptSpan import android.view.View import androidx.appcompat.widget.PopupMenu import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.text.buildSpannedString -import androidx.core.text.inSpans import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner @@ -19,12 +11,12 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.getSummary +import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.ui.image.FaviconDrawable import org.koitharu.kotatsu.core.ui.list.OnTipCloseListener import org.koitharu.kotatsu.core.util.ext.crossfade import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.databinding.ItemSourceConfigBinding @@ -51,15 +43,7 @@ fun sourceConfigItemCheckableDelegate( } bind { - binding.textViewTitle.text = if (item.isNsfw) { - buildSpannedString { - append(item.source.title) - append(' ') - appendNsfwLabel(context) - } - } else { - item.source.title - } + binding.textViewTitle.text = item.source.getTitle(context) binding.switchToggle.isChecked = item.isEnabled binding.switchToggle.isEnabled = item.isAvailable binding.textViewDescription.text = item.source.getSummary(context) @@ -101,15 +85,7 @@ fun sourceConfigItemDelegate2( binding.imageViewMenu.setOnClickListener(eventListener) bind { - binding.textViewTitle.text = if (item.isNsfw) { - buildSpannedString { - append(item.source.title) - append(' ') - appendNsfwLabel(context) - } - } else { - item.source.title - } + binding.textViewTitle.text = item.source.getTitle(context) binding.imageViewAdd.isGone = item.isEnabled || !item.isAvailable binding.imageViewRemove.isVisible = item.isEnabled binding.imageViewMenu.isVisible = item.isEnabled @@ -147,19 +123,6 @@ fun sourceConfigEmptySearchDelegate() = R.layout.item_sources_empty, ) { } -fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans( - ForegroundColorSpan( - context.getThemeColor( - com.google.android.material.R.attr.colorError, - Color.RED, - ), - ), - RelativeSizeSpan(0.74f), - SuperscriptSpan(), -) { - append(context.getString(R.string.nsfw)) -} - private fun showSourceMenu( anchor: View, item: SourceConfigItem.SourceItem, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItemAD.kt index 55c4b412f..4b267125a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogItemAD.kt @@ -1,24 +1,27 @@ package org.koitharu.kotatsu.settings.sources.catalog -import androidx.core.text.buildSpannedString +import androidx.core.view.ViewCompat import androidx.core.view.isVisible +import androidx.core.view.updatePadding import androidx.lifecycle.LifecycleOwner import coil.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier.Companion.ignoreCaptchaErrors import org.koitharu.kotatsu.core.model.getSummary -import org.koitharu.kotatsu.core.model.isNsfw +import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.ui.image.FaviconDrawable import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.util.ext.crossfade import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.core.util.ext.source +import org.koitharu.kotatsu.databinding.ItemCatalogPageBinding import org.koitharu.kotatsu.databinding.ItemEmptyHintBinding import org.koitharu.kotatsu.databinding.ItemSourceCatalogBinding -import org.koitharu.kotatsu.settings.sources.adapter.appendNsfwLabel fun sourceCatalogItemSourceAD( coil: ImageLoader, @@ -35,15 +38,7 @@ fun sourceCatalogItemSourceAD( } bind { - binding.textViewTitle.text = if (item.source.isNsfw()) { - buildSpannedString { - append(item.source.title) - append(' ') - appendNsfwLabel(context) - } - } else { - item.source.title - } + binding.textViewTitle.text = item.source.getTitle(context) if (item.showSummary) { binding.textViewDescription.text = item.source.getSummary(context) binding.textViewDescription.isVisible = true @@ -57,6 +52,7 @@ fun sourceCatalogItemSourceAD( placeholder(fallbackIcon) fallback(fallbackIcon) source(item.source) + ignoreCaptchaErrors() enqueueWith(coil) } } @@ -77,3 +73,30 @@ fun sourceCatalogItemHintAD( binding.textSecondary.setTextAndVisible(item.text) } } + +fun sourceCatalogPageAD( + listener: OnListItemClickListener, + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemCatalogPageBinding.inflate(inflater, parent, false) }, +) { + + val sourcesAdapter = SourcesCatalogAdapter(listener, coil, lifecycleOwner) + with(binding.recyclerView) { + setHasFixedSize(true) + adapter = sourcesAdapter + } + val insetsDelegate = WindowInsetsDelegate() + ViewCompat.setOnApplyWindowInsetsListener(itemView, insetsDelegate) + itemView.addOnLayoutChangeListener(insetsDelegate) + insetsDelegate.addInsetsListener { insets -> + binding.recyclerView.updatePadding( + bottom = insets.bottom + binding.recyclerView.paddingTop, + ) + } + + bind { + sourcesAdapter.items = item.items + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogPage.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogPage.kt new file mode 100644 index 000000000..b129022db --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourceCatalogPage.kt @@ -0,0 +1,19 @@ +package org.koitharu.kotatsu.settings.sources.catalog + +import org.koitharu.kotatsu.list.ui.ListModelDiffCallback +import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.parsers.model.ContentType + +data class SourceCatalogPage( + val type: ContentType, + val items: List, +) : ListModel { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is SourceCatalogPage && other.type == type + } + + override fun getChangePayload(previousState: ListModel): Any? { + return ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt index 1407b05c0..af70d59ae 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt @@ -10,24 +10,21 @@ import androidx.core.view.isVisible import androidx.core.view.updatePadding import coil.ImageLoader import com.google.android.material.appbar.AppBarLayout -import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.core.model.titleResId import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver -import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition -import org.koitharu.kotatsu.core.util.ext.getLocaleDisplayName +import org.koitharu.kotatsu.core.util.ext.getDisplayName import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.databinding.ActivitySourcesCatalogBinding import org.koitharu.kotatsu.main.ui.owners.AppBarOwner -import org.koitharu.kotatsu.parsers.model.ContentType import javax.inject.Inject @AndroidEntryPoint class SourcesCatalogActivity : BaseActivity(), - TabLayout.OnTabSelectedListener, OnListItemClickListener, AppBarOwner, MenuItem.OnActionExpandListener { @@ -43,19 +40,17 @@ class SourcesCatalogActivity : BaseActivity(), super.onCreate(savedInstanceState) setContentView(ActivitySourcesCatalogBinding.inflate(layoutInflater)) supportActionBar?.setDisplayHomeAsUpEnabled(true) - initTabs() - val sourcesAdapter = SourcesCatalogAdapter(this, coil, this) - with(viewBinding.recyclerView) { - setHasFixedSize(true) - adapter = sourcesAdapter - } - viewModel.content.observe(this, sourcesAdapter) + val pagerAdapter = SourcesCatalogPagerAdapter(this, coil, this) + viewBinding.pager.adapter = pagerAdapter + val tabMediator = TabLayoutMediator(viewBinding.tabs, viewBinding.pager, pagerAdapter) + tabMediator.attach() + viewModel.content.observe(this, pagerAdapter) viewModel.onActionDone.observeEvent( this, - ReversibleActionObserver(viewBinding.recyclerView), + ReversibleActionObserver(viewBinding.pager), ) viewModel.locale.observe(this) { - supportActionBar?.subtitle = it.getLocaleDisplayName(this) + supportActionBar?.subtitle = it?.toLocale().getDisplayName(this) } addMenuProvider(SourcesCatalogMenuProvider(this, viewModel, this)) } @@ -65,27 +60,15 @@ class SourcesCatalogActivity : BaseActivity(), left = insets.left, right = insets.right, ) - viewBinding.recyclerView.updatePadding( - bottom = insets.bottom + viewBinding.recyclerView.paddingTop, - ) } override fun onItemClick(item: SourceCatalogItem.Source, view: View) { viewModel.addSource(item.source) } - override fun onTabSelected(tab: TabLayout.Tab) { - viewModel.setContentType(tab.tag as ContentType) - } - - override fun onTabUnselected(tab: TabLayout.Tab) = Unit - - override fun onTabReselected(tab: TabLayout.Tab) { - viewBinding.recyclerView.firstVisibleItemPosition = 0 - } - override fun onMenuItemActionExpand(item: MenuItem): Boolean { viewBinding.tabs.isVisible = false + viewBinding.pager.isUserInputEnabled = false val sq = (item.actionView as? SearchView)?.query?.trim()?.toString().orEmpty() viewModel.performSearch(sq) return true @@ -93,21 +76,8 @@ class SourcesCatalogActivity : BaseActivity(), override fun onMenuItemActionCollapse(item: MenuItem): Boolean { viewBinding.tabs.isVisible = true + viewBinding.pager.isUserInputEnabled = true viewModel.performSearch(null) return true } - - private fun initTabs() { - val tabs = viewBinding.tabs - for (type in ContentType.entries) { - if (viewModel.isNsfwDisabled && type == ContentType.HENTAI) { - continue - } - val tab = tabs.newTab() - tab.setText(type.titleResId) - tab.tag = type - tabs.addTab(tab) - } - tabs.addOnTabSelectedListener(this) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogMenuProvider.kt index db09694b1..67154760e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogMenuProvider.kt @@ -9,7 +9,9 @@ import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.SearchView import androidx.core.view.MenuProvider import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.getLocaleDisplayName +import org.koitharu.kotatsu.core.util.LocaleComparator +import org.koitharu.kotatsu.core.util.ext.getDisplayName +import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.main.ui.owners.AppBarOwner class SourcesCatalogMenuProvider( @@ -57,15 +59,17 @@ class SourcesCatalogMenuProvider( } private fun showLocalesMenu() { - val locales = viewModel.locales.map { - it to it.getLocaleDisplayName(activity) + val locales = viewModel.locales.mapTo(ArrayList(viewModel.locales.size)) { + it to it?.toLocale() } + locales.sortWith(compareBy(nullsFirst(LocaleComparator())) { it.second }) + val anchor: View = (activity as AppBarOwner).appBar.let { it.findViewById(R.id.toolbar) ?: it } val menu = PopupMenu(activity, anchor) for ((i, lc) in locales.withIndex()) { - menu.menu.add(Menu.NONE, Menu.NONE, i, lc.second) + menu.menu.add(Menu.NONE, Menu.NONE, i, lc.second.getDisplayName(activity)) } menu.setOnMenuItemClickListener { viewModel.setLocale(locales.getOrNull(it.order)?.first) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogPagerAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogPagerAdapter.kt new file mode 100644 index 000000000..32f76fe15 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogPagerAdapter.kt @@ -0,0 +1,25 @@ +package org.koitharu.kotatsu.settings.sources.catalog + +import androidx.lifecycle.LifecycleOwner +import coil.ImageLoader +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import org.koitharu.kotatsu.core.model.titleResId +import org.koitharu.kotatsu.core.ui.BaseListAdapter +import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener + +class SourcesCatalogPagerAdapter( + listener: OnListItemClickListener, + coil: ImageLoader, + lifecycleOwner: LifecycleOwner, +) : BaseListAdapter(), TabLayoutMediator.TabConfigurationStrategy { + + init { + delegatesManager.addDelegate(sourceCatalogPageAD(listener, coil, lifecycleOwner)) + } + + override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { + val item = items.getOrNull(position) ?: return + tab.setText(item.type.titleResId) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt index fcef5f987..135c8be4e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogViewModel.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.settings.sources.catalog +import androidx.annotation.MainThread import androidx.lifecycle.viewModelScope import dagger.hilt.android.internal.lifecycle.RetainedLifecycleImpl import dagger.hilt.android.lifecycle.HiltViewModel @@ -8,9 +9,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel @@ -21,6 +23,8 @@ import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.mapToSet +import java.util.EnumMap +import java.util.EnumSet import java.util.Locale import javax.inject.Inject @@ -28,30 +32,23 @@ import javax.inject.Inject class SourcesCatalogViewModel @Inject constructor( private val repository: MangaSourcesRepository, private val listProducerFactory: SourcesCatalogListProducer.Factory, - settings: AppSettings, + private val settings: AppSettings, ) : BaseViewModel() { private val lifecycle = RetainedLifecycleImpl() private var searchQuery: String? = null val onActionDone = MutableEventFlow() - val contentType = MutableStateFlow(ContentType.entries.first()) val locales = repository.allMangaSources.mapToSet { it.locale } val locale = MutableStateFlow(Locale.getDefault().language.takeIf { it in locales }) - val isNsfwDisabled = settings.isNsfwContentDisabled + private val listProducers = locale.map { lc -> + createListProducers(lc) + }.stateIn(viewModelScope, SharingStarted.Eagerly, createListProducers(locale.value)) - private val listProducer: StateFlow = combine( - locale, - contentType, - ) { lc, type -> - listProducerFactory.create(lc, type, lifecycle).also { - it.setQuery(searchQuery) - } - }.stateIn(viewModelScope, SharingStarted.Eagerly, null) - - val content = listProducer.flatMapLatest { - it?.list ?: emptyFlow() - }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + val content: StateFlow> = listProducers.flatMapLatest { + val flows = it.entries.map { (type, producer) -> producer.list.map { x -> SourceCatalogPage(type, x) } } + combine>(flows, Array::toList) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) override fun onCleared() { super.onCleared() @@ -60,21 +57,30 @@ class SourcesCatalogViewModel @Inject constructor( fun performSearch(query: String?) { searchQuery = query - listProducer.value?.setQuery(query) + listProducers.value.forEach { (_, v) -> v.setQuery(query) } } fun setLocale(value: String?) { locale.value = value } - fun setContentType(value: ContentType) { - contentType.value = value - } - fun addSource(source: MangaSource) { launchJob(Dispatchers.Default) { val rollback = repository.setSourceEnabled(source, true) onActionDone.call(ReversibleAction(R.string.source_enabled, rollback)) } } + + @MainThread + private fun createListProducers(lc: String?): Map { + val types = EnumSet.allOf(ContentType::class.java) + if (settings.isNsfwContentDisabled) { + types.remove(ContentType.HENTAI) + } + return types.associateWithTo(EnumMap(ContentType::class.java)) { type -> + listProducerFactory.create(lc, type, lifecycle).also { + it.setQuery(searchQuery) + } + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt index 059824fce..f5a7e051a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt @@ -54,6 +54,7 @@ class SourcesManageFragment : lateinit var shortcutManager: AppShortcutManager private var reorderHelper: ItemTouchHelper? = null + private var sourcesAdapter: SourceConfigAdapter? = null private val viewModel by viewModels() override val recyclerView: RecyclerView @@ -69,7 +70,7 @@ class SourcesManageFragment : savedInstanceState: Bundle?, ) { super.onViewBindingCreated(binding, savedInstanceState) - val sourcesAdapter = SourceConfigAdapter(this, coil, viewLifecycleOwner) + sourcesAdapter = SourceConfigAdapter(this, coil, viewLifecycleOwner) with(binding.recyclerView) { setHasFixedSize(true) adapter = sourcesAdapter @@ -77,7 +78,7 @@ class SourcesManageFragment : it.attachToRecyclerView(this) } } - viewModel.content.observe(viewLifecycleOwner, sourcesAdapter) + viewModel.content.observe(viewLifecycleOwner, checkNotNull(sourcesAdapter)) viewModel.onActionDone.observeEvent( viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView), @@ -91,6 +92,7 @@ class SourcesManageFragment : } override fun onDestroyView() { + sourcesAdapter = null reorderHelper = null super.onDestroyView() } @@ -204,7 +206,7 @@ class SourcesManageFragment : y: Int, ) { super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y) - viewModel.reorderSources(fromPos, toPos) + sourcesAdapter?.reorderItems(fromPos, toPos) } override fun canDropOver( @@ -248,5 +250,10 @@ class SourcesManageFragment : } override fun isLongPressDragEnabled() = true + + override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + super.clearView(recyclerView, viewHolder) + viewModel.saveSourcesOrder(sourcesAdapter?.items ?: return) + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt index 5ad9d147c..daa7108ab 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageViewModel.kt @@ -4,8 +4,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.yield import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.removeObserverAsync @@ -43,17 +41,19 @@ class SourcesManageViewModel @Inject constructor( database.invalidationTracker.removeObserverAsync(listProducer) } - fun reorderSources(oldPos: Int, newPos: Int) { - val snapshot = content.value.toMutableList() - if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) { - return - } - if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) { - return + fun saveSourcesOrder(snapshot: List) { + val prevJob = commitJob + commitJob = launchJob(Dispatchers.Default) { + prevJob?.cancelAndJoin() + val newSourcesList = snapshot.mapNotNull { x -> + if (x is SourceConfigItem.SourceItem && x.isDraggable) { + x.source + } else { + null + } + } + repository.setPositions(newSourcesList) } - snapshot.move(oldPos, newPos) - content.value = snapshot - commit(snapshot) } fun canReorder(oldPos: Int, newPos: Int): Boolean { @@ -72,28 +72,31 @@ class SourcesManageViewModel @Inject constructor( } fun bringToTop(source: MangaSource) { - var oldPos = -1 - var newPos = -1 val snapshot = content.value - for ((i, x) in snapshot.withIndex()) { - if (x !is SourceConfigItem.SourceItem) { - continue - } - if (newPos == -1) { - newPos = i - } - if (x.source == source) { - oldPos = i - break + launchJob(Dispatchers.Default) { + var oldPos = -1 + var newPos = -1 + for ((i, x) in snapshot.withIndex()) { + if (x !is SourceConfigItem.SourceItem) { + continue + } + if (newPos == -1) { + newPos = i + } + if (x.source == source) { + oldPos = i + break + } } - } - @Suppress("KotlinConstantConditions") - if (oldPos != -1 && newPos != -1) { - reorderSources(oldPos, newPos) - val revert = ReversibleAction(R.string.moved_to_top) { - reorderSources(newPos, oldPos) + @Suppress("KotlinConstantConditions") + if (oldPos != -1 && newPos != -1) { + reorderSources(oldPos, newPos) + val revert = ReversibleAction(R.string.moved_to_top) { + reorderSources(newPos, oldPos) + } + commitJob?.join() + onActionDone.call(revert) } - onActionDone.call(revert) } } @@ -113,20 +116,15 @@ class SourcesManageViewModel @Inject constructor( } } - private fun commit(snapshot: List) { - val prevJob = commitJob - commitJob = launchJob { - prevJob?.cancelAndJoin() - delay(500) - val newSourcesList = snapshot.mapNotNull { x -> - if (x is SourceConfigItem.SourceItem && x.isDraggable) { - x.source - } else { - null - } - } - repository.setPositions(newSourcesList) - yield() + private fun reorderSources(oldPos: Int, newPos: Int) { + val snapshot = content.value.toMutableList() + if ((snapshot[oldPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) { + return } + if ((snapshot[newPos] as? SourceConfigItem.SourceItem)?.isDraggable != true) { + return + } + snapshot.move(oldPos, newPos) + saveSourcesOrder(snapshot) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryModel.kt index 7c274458d..a606c7abf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/DirectoryModel.kt @@ -9,9 +9,11 @@ data class DirectoryModel( val title: String?, @StringRes val titleRes: Int, val file: File?, + val isRemovable: Boolean, val isChecked: Boolean, val isAvailable: Boolean, ) : ListModel { + override fun areItemsTheSame(other: ListModel): Boolean { return other is DirectoryModel && other.file == file && other.title == title && other.titleRes == titleRes } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectViewModel.kt index 7985c9bda..3df94e208 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectViewModel.kt @@ -64,6 +64,7 @@ class MangaDirectorySelectViewModel @Inject constructor( file = dir, isChecked = dir == defaultValue, isAvailable = true, + isRemovable = false, ) } this += DirectoryModel( @@ -72,6 +73,7 @@ class MangaDirectorySelectViewModel @Inject constructor( file = null, isChecked = false, isAvailable = true, + isRemovable = false, ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigAD.kt index e4aa8c5fe..d1d823a14 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/DirectoryConfigAD.kt @@ -21,11 +21,16 @@ fun directoryConfigAD( bind { binding.textViewTitle.text = item.title ?: getString(item.titleRes) binding.textViewSubtitle.textAndVisible = item.file?.absolutePath - binding.imageViewRemove.isVisible = item.isChecked - binding.textViewTitle.drawableStart = if (item.isAvailable) { - null + binding.imageViewRemove.isVisible = item.isRemovable + binding.imageViewRemove.isEnabled = !item.isChecked + binding.textViewTitle.drawableStart = if (!item.isAvailable) { + ContextCompat.getDrawable(context, R.drawable.ic_alert_outline)?.apply { + setTint(ContextCompat.getColor(context, R.color.warning)) + } + } else if (item.isChecked) { + ContextCompat.getDrawable(context, R.drawable.ic_download) } else { - ContextCompat.getDrawable(context, R.drawable.ic_alert_outline) + null } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt index bad65b98f..b53cc05de 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt @@ -59,16 +59,18 @@ class MangaDirectoriesViewModel @Inject constructor( val prevJob = loadingJob loadingJob = launchJob(Dispatchers.Default) { prevJob?.cancelAndJoin() + val downloadDir = storageManager.getDefaultWriteableDir() val applicationDirs = storageManager.getApplicationStorageDirs() - val customDirs = settings.userSpecifiedMangaDirectories + val customDirs = settings.userSpecifiedMangaDirectories - applicationDirs items.value = buildList(applicationDirs.size + customDirs.size) { applicationDirs.mapTo(this) { dir -> DirectoryModel( title = storageManager.getDirectoryDisplayName(dir, isFullPath = false), titleRes = 0, file = dir, - isChecked = false, + isChecked = dir == downloadDir, isAvailable = dir.canRead() && dir.canWrite(), + isRemovable = false, ) } customDirs.mapTo(this) { dir -> @@ -76,8 +78,9 @@ class MangaDirectoriesViewModel @Inject constructor( title = storageManager.getDirectoryDisplayName(dir, isFullPath = false), titleRes = 0, file = dir, - isChecked = true, + isChecked = dir == downloadDir, isAvailable = dir.canRead() && dir.canWrite(), + isRemovable = true, ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt index 18a2289ba..3875156a6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt @@ -1,43 +1,36 @@ package org.koitharu.kotatsu.settings.tracker -import android.annotation.SuppressLint -import android.content.ActivityNotFoundException -import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.net.Uri import android.os.Build import android.os.Bundle -import android.os.PowerManager import android.provider.Settings import android.text.style.URLSpan import android.view.View -import androidx.core.net.toUri import androidx.core.text.buildSpannedString import androidx.core.text.inSpans import androidx.fragment.app.viewModels import androidx.preference.MultiSelectListPreference import androidx.preference.Preference -import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.powerManager import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet +import org.koitharu.kotatsu.settings.utils.DozeHelper import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider import org.koitharu.kotatsu.tracker.work.TrackerNotificationChannels import javax.inject.Inject -private const val KEY_IGNORE_DOZE = "ignore_dose" - @AndroidEntryPoint class TrackerSettingsFragment : BasePreferenceFragment(R.string.check_for_new_chapters), SharedPreferences.OnSharedPreferenceChangeListener { private val viewModel by viewModels() + private val dozeHelper = DozeHelper(this) @Inject lateinit var channels: TrackerNotificationChannels @@ -57,13 +50,12 @@ class TrackerSettingsFragment : } } } - updateDozePreference() + dozeHelper.updatePreference() updateCategoriesEnabled() } override fun onResume() { super.onResume() - updateDozePreference() updateNotificationsSummary() } @@ -111,8 +103,8 @@ class TrackerSettingsFragment : true } - KEY_IGNORE_DOZE -> { - startIgnoreDoseActivity(preference.context) + AppSettings.KEY_IGNORE_DOZE -> { + dozeHelper.startIgnoreDoseActivity() true } @@ -120,12 +112,6 @@ class TrackerSettingsFragment : } } - private fun updateDozePreference() { - findPreference(KEY_IGNORE_DOZE)?.run { - isVisible = isDozeIgnoreAvailable(context) - } - } - private fun updateNotificationsSummary() { val pref = findPreference(AppSettings.KEY_NOTIFICATIONS_SETTINGS) ?: return pref.setSummary( @@ -148,34 +134,4 @@ class TrackerSettingsFragment : getString(R.string.enabled_d_of_d, count[0], count[1]) } } - - @SuppressLint("BatteryLife") - private fun startIgnoreDoseActivity(context: Context) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() - return - } - val packageName = context.packageName - val powerManager = context.powerManager ?: return - if (!powerManager.isIgnoringBatteryOptimizations(packageName)) { - try { - val intent = Intent( - Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, - "package:$packageName".toUri(), - ) - startActivity(intent) - } catch (e: ActivityNotFoundException) { - Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() - } - } - } - - private fun isDozeIgnoreAvailable(context: Context): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - return false - } - val packageName = context.packageName - val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager - return !powerManager.isIgnoringBatteryOptimizations(packageName) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt index 2102b3788..ed9aabd94 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/AboutLinksPreference.kt @@ -7,8 +7,10 @@ import android.util.AttributeSet import android.view.View import androidx.appcompat.widget.TooltipCompat import androidx.core.net.toUri +import androidx.core.view.forEach import androidx.preference.Preference import androidx.preference.PreferenceViewHolder +import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R import org.koitharu.kotatsu.databinding.PreferenceAboutLinksBinding @@ -27,12 +29,7 @@ class AboutLinksPreference @JvmOverloads constructor( super.onBindViewHolder(holder) val binding = PreferenceAboutLinksBinding.bind(holder.itemView) - arrayOf( - binding.btn4pda, - binding.btnDiscord, - binding.btnGithub, - binding.btnTelegram, - ).forEach { button -> + binding.root.forEach { button -> TooltipCompat.setTooltipText(button, button.contentDescription) button.setOnClickListener(this) } @@ -40,16 +37,15 @@ class AboutLinksPreference @JvmOverloads constructor( override fun onClick(v: View) { val urlResId = when (v.id) { - R.id.btn_4pda -> R.string.url_forpda R.id.btn_discord -> R.string.url_discord R.id.btn_telegram -> R.string.url_telegram R.id.btn_github -> R.string.url_github else -> return } - openLink(v.context.getString(urlResId), v.contentDescription) + openLink(v, v.context.getString(urlResId), v.contentDescription) } - private fun openLink(url: String, title: CharSequence?) { + private fun openLink(v: View, url: String, title: CharSequence?) { val intent = Intent(Intent.ACTION_VIEW, url.toUri()) try { context.startActivity( @@ -60,6 +56,7 @@ class AboutLinksPreference @JvmOverloads constructor( }, ) } catch (_: ActivityNotFoundException) { + Snackbar.make(v, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/DozeHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/DozeHelper.kt new file mode 100644 index 000000000..35d0dc359 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/DozeHelper.kt @@ -0,0 +1,69 @@ +package org.koitharu.kotatsu.settings.utils + +import android.annotation.SuppressLint +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.net.toUri +import androidx.preference.Preference +import androidx.preference.PreferenceFragmentCompat +import com.google.android.material.snackbar.Snackbar +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.ext.powerManager + +@SuppressLint("BatteryLife") +class DozeHelper( + private val fragment: PreferenceFragmentCompat, +) { + + private val startForDozeResult = fragment.registerForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { + updatePreference() + } + + fun updatePreference() { + val preference = fragment.findPreference(AppSettings.KEY_IGNORE_DOZE) ?: return + preference.isVisible = isDozeIgnoreAvailable() + } + + fun startIgnoreDoseActivity(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + Snackbar.make(fragment.listView ?: return false, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() + return false + } + val context = fragment.context ?: return false + val packageName = context.packageName + val powerManager = context.powerManager ?: return false + return if (!powerManager.isIgnoringBatteryOptimizations(packageName)) { + try { + val intent = Intent( + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + "package:$packageName".toUri(), + ) + startForDozeResult.launch(intent) + true + } catch (e: ActivityNotFoundException) { + Snackbar.make(fragment.listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() + false + } + } else { + false + } + } + + private fun isDozeIgnoreAvailable(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return false + } + val context = fragment.context ?: return false + val packageName = context.packageName + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + return !powerManager.isIgnoringBatteryOptimizations(packageName) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextSummaryProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextSummaryProvider.kt deleted file mode 100644 index 447092234..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextSummaryProvider.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.koitharu.kotatsu.settings.utils - -import androidx.annotation.StringRes -import androidx.preference.EditTextPreference -import androidx.preference.Preference - -class EditTextSummaryProvider(@StringRes private val emptySummaryId: Int) : - Preference.SummaryProvider { - - override fun provideSummary(preference: EditTextPreference): CharSequence { - val text = preference.text - return if (text.isNullOrEmpty()) { - preference.context.getString(emptySummaryId) - } else { - text - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt index dc0876396..406735fb9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt @@ -31,6 +31,7 @@ import javax.inject.Inject import javax.inject.Provider private const val NO_ID = 0L +private const val MAX_QUERY_IDS = 100 @Reusable class TrackingRepository @Inject constructor( @@ -65,7 +66,14 @@ class TrackingRepository @Inject constructor( suspend fun getTracks(mangaList: Collection): List { val ids = mangaList.mapToSet { it.id } - val tracks = db.getTracksDao().findAll(ids).groupBy { it.mangaId } + val dao = db.getTracksDao() + val tracks = if (ids.size <= MAX_QUERY_IDS) { + dao.findAll(ids) + } else { + // TODO split tracks in the worker + ids.windowed(MAX_QUERY_IDS, MAX_QUERY_IDS, true) + .flatMap { dao.findAll(it) } + }.groupBy { it.mangaId } val idSet = HashSet() val result = ArrayList(mangaList.size) for (item in mangaList) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt index 6c14436df..c6d9faf98 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt @@ -25,7 +25,7 @@ import javax.inject.Inject @HiltViewModel class UpdatesViewModel @Inject constructor( private val repository: TrackingRepository, - private val settings: AppSettings, + settings: AppSettings, private val extraProvider: ListExtraProvider, downloadScheduler: DownloadWorker.Scheduler, ) : MangaListViewModel(settings, downloadScheduler) { diff --git a/app/src/main/res/color-v23/bottom_menu_background.xml b/app/src/main/res/color-v23/bottom_menu_background.xml deleted file mode 100644 index 1a8aa582c..000000000 --- a/app/src/main/res/color-v23/bottom_menu_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/app/src/main/res/color/bottom_menu_active_item.xml b/app/src/main/res/color/bottom_menu_active_item.xml index 715da6df6..a56980d3c 100644 --- a/app/src/main/res/color/bottom_menu_active_item.xml +++ b/app/src/main/res/color/bottom_menu_active_item.xml @@ -1,5 +1,5 @@ - + diff --git a/app/src/main/res/color/bottom_menu_background.xml b/app/src/main/res/color/bottom_menu_background.xml deleted file mode 100644 index a43c25c04..000000000 --- a/app/src/main/res/color/bottom_menu_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable-night/avd_splash.xml b/app/src/main/res/drawable-night/avd_splash.xml new file mode 100644 index 000000000..ef6c0b088 --- /dev/null +++ b/app/src/main/res/drawable-night/avd_splash.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable-v23/m3_spinner_popup_background.xml b/app/src/main/res/drawable-v23/m3_spinner_popup_background.xml new file mode 100644 index 000000000..04ee60a0a --- /dev/null +++ b/app/src/main/res/drawable-v23/m3_spinner_popup_background.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/avd_splash.xml b/app/src/main/res/drawable/avd_splash.xml index 205c9b139..2c52ce779 100644 --- a/app/src/main/res/drawable/avd_splash.xml +++ b/app/src/main/res/drawable/avd_splash.xml @@ -1,64 +1,131 @@ - + - - - + android:name="splash" + android:width="320dp" + android:height="320dp" + android:viewportWidth="320" + android:viewportHeight="320"> + + + + + + + + + + + + - + - + + + + - + - + + + + - + - + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_4pda.xml b/app/src/main/res/drawable/ic_4pda.xml deleted file mode 100644 index f0920f0d8..000000000 --- a/app/src/main/res/drawable/ic_4pda.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_welcome.xml b/app/src/main/res/drawable/ic_welcome.xml new file mode 100644 index 000000000..7063392d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_welcome.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/m3_spinner_popup_background.xml b/app/src/main/res/drawable/m3_spinner_popup_background.xml new file mode 100644 index 000000000..9cb3a631a --- /dev/null +++ b/app/src/main/res/drawable/m3_spinner_popup_background.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-w600dp-land/activity_color_filter.xml b/app/src/main/res/layout-w600dp-land/activity_color_filter.xml index 88060cdeb..88bbbdb14 100644 --- a/app/src/main/res/layout-w600dp-land/activity_color_filter.xml +++ b/app/src/main/res/layout-w600dp-land/activity_color_filter.xml @@ -127,6 +127,18 @@ app:layout_constraintStart_toEndOf="@id/guideline_vertical" app:layout_constraintTop_toTopOf="parent" /> + + + app:layout_constraintTop_toBottomOf="@id/switch_grayscale" /> - - - - - diff --git a/app/src/main/res/layout-w600dp-land/activity_main.xml b/app/src/main/res/layout-w600dp-land/activity_main.xml index 8cbbe62e9..c83309bb4 100644 --- a/app/src/main/res/layout-w600dp-land/activity_main.xml +++ b/app/src/main/res/layout-w600dp-land/activity_main.xml @@ -15,8 +15,7 @@ app:elevation="1dp" app:headerLayout="@layout/navigation_rail_fab" app:labelVisibilityMode="labeled" - app:layout_constraintStart_toStartOf="parent" - tools:menu="@menu/nav_bottom" /> + app:layout_constraintStart_toStartOf="parent" /> + tools:layout="@layout/sheet_tags" /> diff --git a/app/src/main/res/layout/activity_color_filter.xml b/app/src/main/res/layout/activity_color_filter.xml index 21850f5ee..c38c88891 100644 --- a/app/src/main/res/layout/activity_color_filter.xml +++ b/app/src/main/res/layout/activity_color_filter.xml @@ -111,6 +111,17 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/imageView_before" /> + + + app:layout_constraintTop_toBottomOf="@id/switch_grayscale" /> - - - -