Option to override manga title and cover

master
Koitharu 1 year ago
parent d542fa6bb6
commit bd4fecc3b6
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -208,6 +208,9 @@
android:launchMode="singleTop" /> android:launchMode="singleTop" />
<activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" /> <activity android:name="org.koitharu.kotatsu.image.ui.ImageActivity" />
<activity android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity" /> <activity android:name="org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity" />
<activity
android:name="org.koitharu.kotatsu.settings.override.OverrideConfigActivity"
android:label="@string/edit" />
<activity <activity
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity" android:name="org.koitharu.kotatsu.sync.ui.SyncAuthActivity"
android:exported="true" android:exported="true"

@ -8,3 +8,4 @@ const val TABLE_HISTORY = "history"
const val TABLE_MANGA_TAGS = "manga_tags" const val TABLE_MANGA_TAGS = "manga_tags"
const val TABLE_SOURCES = "sources" const val TABLE_SOURCES = "sources"
const val TABLE_CHAPTERS = "chapters" const val TABLE_CHAPTERS = "chapters"
const val TABLE_PREFERENCES = "preferences"

@ -15,6 +15,9 @@ abstract class PreferencesDao {
@Query("SELECT * FROM preferences WHERE manga_id = :mangaId") @Query("SELECT * FROM preferences WHERE manga_id = :mangaId")
abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?> abstract fun observe(mangaId: Long): Flow<MangaPrefsEntity?>
@Query("SELECT * FROM preferences WHERE title_override IS NOT NULL OR cover_override IS NOT NULL OR content_rating_override IS NOT NULL")
abstract suspend fun getOverrides(): List<MangaPrefsEntity>
@Query("UPDATE preferences SET cf_brightness = 0, cf_contrast = 0, cf_invert = 0, cf_grayscale = 0") @Query("UPDATE preferences SET cf_brightness = 0, cf_contrast = 0, cf_invert = 0, cf_grayscale = 0")
abstract suspend fun resetColorFilters() abstract suspend fun resetColorFilters()

@ -4,9 +4,10 @@ import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import org.koitharu.kotatsu.core.db.TABLE_PREFERENCES
@Entity( @Entity(
tableName = "preferences", tableName = TABLE_PREFERENCES,
foreignKeys = [ foreignKeys = [
ForeignKey( ForeignKey(
entity = MangaEntity::class, entity = MangaEntity::class,

@ -11,6 +11,7 @@ import androidx.core.os.LocaleListCompat
import androidx.core.text.buildSpannedString import androidx.core.text.buildSpannedString
import androidx.core.text.strikeThrough import androidx.core.text.strikeThrough
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.model.MangaOverride
import org.koitharu.kotatsu.core.util.ext.iterator import org.koitharu.kotatsu.core.util.ext.iterator
import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentRating
@ -20,6 +21,7 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.util.findById import org.koitharu.kotatsu.parsers.util.findById
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.mapToSet
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@ -192,3 +194,14 @@ fun MangaChapter.getLocalizedTitle(resources: Resources, index: Int = -1): Strin
else -> resources.getString(R.string.unnamed_chapter) else -> resources.getString(R.string.unnamed_chapter)
} }
} }
fun Manga.withOverride(override: MangaOverride?) = if (override != null) {
copy(
title = override.title.ifNullOrEmpty { title },
coverUrl = override.coverUrl.ifNullOrEmpty { coverUrl },
largeCoverUrl = override.coverUrl.ifNullOrEmpty { largeCoverUrl },
contentRating = override.contentRating ?: contentRating,
)
} else {
this
}

@ -95,6 +95,7 @@ import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.about.AppUpdateActivity import org.koitharu.kotatsu.settings.about.AppUpdateActivity
import org.koitharu.kotatsu.settings.backup.BackupDialogFragment import org.koitharu.kotatsu.settings.backup.BackupDialogFragment
import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment
import org.koitharu.kotatsu.settings.override.OverrideConfigActivity
import org.koitharu.kotatsu.settings.reader.ReaderTapGridConfigActivity import org.koitharu.kotatsu.settings.reader.ReaderTapGridConfigActivity
import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity
import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
@ -249,6 +250,12 @@ class AppRouter private constructor(
startActivity(mangaUpdatesIntent(contextOrNull() ?: return)) startActivity(mangaUpdatesIntent(contextOrNull() ?: return))
} }
fun openMangaOverrideConfig(manga: Manga) {
val intent = Intent(contextOrNull() ?: return, OverrideConfigActivity::class.java)
.putExtra(KEY_MANGA, ParcelableManga(manga, withDescription = false))
startActivity(intent)
}
fun openSettings() = startActivity(SettingsActivity::class.java) fun openSettings() = startActivity(SettingsActivity::class.java)
fun openReaderSettings() { fun openReaderSettings() {

@ -1,5 +1,7 @@
package org.koitharu.kotatsu.core.parser package org.koitharu.kotatsu.core.parser
import androidx.collection.LongObjectMap
import androidx.collection.MutableLongObjectMap
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.room.withTransaction import androidx.room.withTransaction
import dagger.Reusable import dagger.Reusable
@ -7,6 +9,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.TABLE_PREFERENCES
import org.koitharu.kotatsu.core.db.entity.ContentRating
import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity
import org.koitharu.kotatsu.core.db.entity.toEntities import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toEntity
@ -17,10 +21,12 @@ import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.nav.MangaIntent import org.koitharu.kotatsu.core.nav.MangaIntent
import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.os.AppShortcutManager
import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.ReaderMode
import org.koitharu.kotatsu.core.ui.model.MangaOverride
import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.reader.domain.ReaderColorFilter import org.koitharu.kotatsu.reader.domain.ReaderColorFilter
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Provider import javax.inject.Provider
@ -67,6 +73,33 @@ class MangaDataRepository @Inject constructor(
return db.getPreferencesDao().find(mangaId)?.getColorFilterOrNull() return db.getPreferencesDao().find(mangaId)?.getColorFilterOrNull()
} }
suspend fun getOverride(mangaId: Long): MangaOverride? {
return db.getPreferencesDao().find(mangaId)?.getOverrideOrNull()
}
suspend fun getOverrides(): LongObjectMap<MangaOverride> {
val entities = db.getPreferencesDao().getOverrides()
val map = MutableLongObjectMap<MangaOverride>(entities.size)
for (entity in entities) {
map[entity.mangaId] = entity.getOverrideOrNull() ?: continue
}
return map
}
suspend fun setOverride(mangaId: Long, override: MangaOverride?) {
db.withTransaction {
val dao = db.getPreferencesDao()
val entity = dao.find(mangaId) ?: newEntity(mangaId)
dao.upsert(
entity.copy(
titleOverride = override?.title?.nullIfEmpty(),
coverUrlOverride = override?.coverUrl?.nullIfEmpty(),
contentRatingOverride = override?.contentRating?.name,
),
)
}
}
fun observeColorFilter(mangaId: Long): Flow<ReaderColorFilter?> { fun observeColorFilter(mangaId: Long): Flow<ReaderColorFilter?> {
return db.getPreferencesDao().observe(mangaId) return db.getPreferencesDao().observe(mangaId)
.map { it?.getColorFilterOrNull() } .map { it?.getColorFilterOrNull() }
@ -146,6 +179,11 @@ class MangaDataRepository @Inject constructor(
} }
} }
fun observeOverridesTrigger(emitInitialState: Boolean) = db.invalidationTracker.createFlow(
tables = arrayOf(TABLE_PREFERENCES),
emitInitialState = emitInitialState,
)
private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? { private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? {
return if (cfBrightness != 0f || cfContrast != 0f || cfInvert || cfGrayscale) { return if (cfBrightness != 0f || cfContrast != 0f || cfInvert || cfGrayscale) {
ReaderColorFilter(cfBrightness, cfContrast, cfInvert, cfGrayscale) ReaderColorFilter(cfBrightness, cfContrast, cfInvert, cfGrayscale)
@ -154,6 +192,18 @@ class MangaDataRepository @Inject constructor(
} }
} }
private fun MangaPrefsEntity.getOverrideOrNull(): MangaOverride? {
return if (titleOverride.isNullOrEmpty() && coverUrlOverride.isNullOrEmpty() && contentRatingOverride.isNullOrEmpty()) {
null
} else {
MangaOverride(
coverUrl = coverUrlOverride?.nullIfEmpty(),
title = titleOverride?.nullIfEmpty(),
contentRating = ContentRating(contentRatingOverride),
)
}
}
private fun newEntity(mangaId: Long) = MangaPrefsEntity( private fun newEntity(mangaId: Long) = MangaPrefsEntity(
mangaId = mangaId, mangaId = mangaId,
mode = -1, mode = -1,

@ -0,0 +1,9 @@
package org.koitharu.kotatsu.core.ui.model
import org.koitharu.kotatsu.parsers.model.ContentRating
data class MangaOverride(
val coverUrl: String?,
val title: String?,
val contentRating: ContentRating?,
)

@ -2,6 +2,8 @@ package org.koitharu.kotatsu.details.data
import org.koitharu.kotatsu.core.model.getLocale import org.koitharu.kotatsu.core.model.getLocale
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.model.withOverride
import org.koitharu.kotatsu.core.ui.model.MangaOverride
import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
@ -13,6 +15,7 @@ import java.util.Locale
data class MangaDetails( data class MangaDetails(
private val manga: Manga, private val manga: Manga,
private val localManga: LocalManga?, private val localManga: LocalManga?,
private val override: MangaOverride?,
val description: CharSequence?, val description: CharSequence?,
val isLoaded: Boolean, val isLoaded: Boolean,
) { ) {
@ -34,12 +37,13 @@ data class MangaDetails(
get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null
val coverUrl: String? val coverUrl: String?
get() = manga.largeCoverUrl get() = override?.coverUrl
.ifNullOrEmpty { manga.largeCoverUrl }
.ifNullOrEmpty { manga.coverUrl } .ifNullOrEmpty { manga.coverUrl }
.ifNullOrEmpty { localManga?.manga?.coverUrl } .ifNullOrEmpty { localManga?.manga?.coverUrl }
?.nullIfEmpty() ?.nullIfEmpty()
fun toManga() = manga fun toManga() = manga.withOverride(override)
fun getLocale(): Locale? { fun getLocale(): Locale? {
findAppropriateLocale(chapters.keys.singleOrNull())?.let { findAppropriateLocale(chapters.keys.singleOrNull())?.let {
@ -48,13 +52,11 @@ data class MangaDetails(
return manga.source.getLocale() return manga.source.getLocale()
} }
fun filterChapters(branch: String?) = MangaDetails( fun filterChapters(branch: String?) = copy(
manga = manga.filterChapters(branch), manga = manga.filterChapters(branch),
localManga = localManga?.run { localManga = localManga?.run {
copy(manga = manga.filterChapters(branch)) copy(manga = manga.filterChapters(branch))
}, },
description = description,
isLoaded = isLoaded,
) )
private fun mergeChapters(): List<MangaChapter> { private fun mergeChapters(): List<MangaChapter> {

@ -52,7 +52,16 @@ class DetailsLoadUseCase @Inject constructor(
m m
} }
} }
send(MangaDetails(manga, null, null, false)) val override = mangaDataRepository.getOverride(manga.id)
send(
MangaDetails(
manga = manga,
localManga = null,
override = override,
description = null,
isLoaded = false,
),
)
val local = if (!manga.isLocal) { val local = if (!manga.isLocal) {
async { async {
localMangaRepository.findSavedManga(manga) localMangaRepository.findSavedManga(manga)
@ -66,28 +75,31 @@ class DetailsLoadUseCase @Inject constructor(
launch { updateTracker(details) } launch { updateTracker(details) }
send( send(
MangaDetails( MangaDetails(
details, manga = details,
local?.peek(), localManga = local?.peek(),
details.description?.parseAsHtml(withImages = false)?.trim(), override = override,
false, description = details.description?.parseAsHtml(withImages = false)?.trim(),
isLoaded = false,
), ),
) )
send( send(
MangaDetails( MangaDetails(
details, manga = details,
local?.await(), localManga = local?.await(),
details.description?.parseAsHtml(withImages = true)?.trim(), override = override,
true, description = details.description?.parseAsHtml(withImages = true)?.trim(),
isLoaded = true,
), ),
) )
} catch (e: IOException) { } catch (e: IOException) {
local?.await()?.manga?.also { localManga -> local?.await()?.manga?.also { localManga ->
send( send(
MangaDetails( MangaDetails(
localManga, manga = localManga,
null, localManga = null,
localManga.description?.parseAsHtml(withImages = false)?.trim(), override = override,
true, description = localManga.description?.parseAsHtml(withImages = false)?.trim(),
isLoaded = true,
), ),
) )
} ?: close(e) } ?: close(e)

@ -87,7 +87,7 @@ class DetailsViewModel @Inject constructor(
val mangaId = intent.mangaId val mangaId = intent.mangaId
init { init {
mangaDetails.value = intent.manga?.let { MangaDetails(it, null, null, false) } mangaDetails.value = intent.manga?.let { MangaDetails(it, null, null, null, false) }
} }
val history = historyRepository.observeOne(mangaId) val history = historyRepository.observeOne(mangaId)

@ -14,12 +14,12 @@ import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
@ -34,8 +34,8 @@ class RelatedListViewModel @Inject constructor(
mangaRepositoryFactory: MangaRepository.Factory, mangaRepositoryFactory: MangaRepository.Factory,
settings: AppSettings, settings: AppSettings,
private val mangaListMapper: MangaListMapper, private val mangaListMapper: MangaListMapper,
downloadScheduler: DownloadWorker.Scheduler, mangaDataRepository: MangaDataRepository,
) : MangaListViewModel(settings, downloadScheduler) { ) : MangaListViewModel(settings, mangaDataRepository) {
private val seed = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga private val seed = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga
private val repository = mangaRepositoryFactory.create(seed.source) private val repository = mangaRepositoryFactory.create(seed.source)

@ -93,7 +93,7 @@ class FavouritesCategoryEditActivity :
} }
override fun afterTextChanged(s: Editable?) { override fun afterTextChanged(s: Editable?) {
viewBinding.buttonDone.isEnabled = !s.isNullOrBlank() viewBinding.buttonDone.isEnabled = !s.isNullOrBlank() && !viewModel.isLoading.value
} }
override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
@ -119,6 +119,7 @@ class FavouritesCategoryEditActivity :
} }
private fun onLoadingStateChanged(isLoading: Boolean) { private fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.buttonDone.isEnabled = !isLoading && !viewBinding.editName.text.isNullOrBlank()
viewBinding.editSort.isEnabled = !isLoading viewBinding.editSort.isEnabled = !isLoading
viewBinding.editName.isEnabled = !isLoading viewBinding.editName.isEnabled = !isLoading
viewBinding.switchTracker.isEnabled = !isLoading viewBinding.switchTracker.isEnabled = !isLoading

@ -17,13 +17,13 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.flattenLatest import org.koitharu.kotatsu.core.util.ext.flattenLatest
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.favourites.domain.FavoritesListQuickFilter import org.koitharu.kotatsu.favourites.domain.FavoritesListQuickFilter
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
@ -51,8 +51,8 @@ class FavouritesListViewModel @Inject constructor(
private val markAsReadUseCase: MarkAsReadUseCase, private val markAsReadUseCase: MarkAsReadUseCase,
quickFilterFactory: FavoritesListQuickFilter.Factory, quickFilterFactory: FavoritesListQuickFilter.Factory,
settings: AppSettings, settings: AppSettings,
downloadScheduler: DownloadWorker.Scheduler, mangaDataRepository: MangaDataRepository,
) : MangaListViewModel(settings, downloadScheduler), QuickFilterListener { ) : MangaListViewModel(settings, mangaDataRepository), QuickFilterListener {
val categoryId: Long = savedStateHandle[AppRouter.KEY_ID] ?: NO_ID val categoryId: Long = savedStateHandle[AppRouter.KEY_ID] ?: NO_ID
private val quickFilter = quickFilterFactory.create(categoryId) private val quickFilter = quickFilterFactory.create(categoryId)
@ -92,7 +92,8 @@ class FavouritesListViewModel @Inject constructor(
override fun onRetry() = Unit override fun onRetry() = Unit
override fun setFilterOption(option: ListFilterOption, isApplied: Boolean) = quickFilter.setFilterOption(option, isApplied) override fun setFilterOption(option: ListFilterOption, isApplied: Boolean) =
quickFilter.setFilterOption(option, isApplied)
override fun toggleFilterOption(option: ListFilterOption) = quickFilter.toggleFilterOption(option) override fun toggleFilterOption(option: ListFilterOption) = quickFilter.toggleFilterOption(option)

@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
@ -22,7 +23,6 @@ import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.flattenLatest import org.koitharu.kotatsu.core.util.ext.flattenLatest
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.domain.HistoryListQuickFilter import org.koitharu.kotatsu.history.domain.HistoryListQuickFilter
import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase
@ -53,8 +53,8 @@ class HistoryListViewModel @Inject constructor(
private val mangaListMapper: MangaListMapper, private val mangaListMapper: MangaListMapper,
private val markAsReadUseCase: MarkAsReadUseCase, private val markAsReadUseCase: MarkAsReadUseCase,
private val quickFilter: HistoryListQuickFilter, private val quickFilter: HistoryListQuickFilter,
downloadScheduler: DownloadWorker.Scheduler, mangaDataRepository: MangaDataRepository,
) : MangaListViewModel(settings, downloadScheduler), QuickFilterListener by quickFilter { ) : MangaListViewModel(settings, mangaDataRepository), QuickFilterListener by quickFilter {
private val sortOrder: StateFlow<ListSortOrder> = settings.observeAsStateFlow( private val sortOrder: StateFlow<ListSortOrder> = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.IO, scope = viewModelScope + Dispatchers.IO,

@ -6,10 +6,13 @@ import androidx.annotation.ColorRes
import androidx.annotation.IntDef import androidx.annotation.IntDef
import androidx.collection.MutableScatterSet import androidx.collection.MutableScatterSet
import androidx.collection.ScatterSet import androidx.collection.ScatterSet
import dagger.Reusable
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.model.MangaOverride
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.history.data.HistoryRepository
@ -20,11 +23,11 @@ import org.koitharu.kotatsu.list.ui.model.MangaListModel
import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Singleton @Reusable
class MangaListMapper @Inject constructor( class MangaListMapper @Inject constructor(
@ApplicationContext context: Context, @ApplicationContext context: Context,
private val settings: AppSettings, private val settings: AppSettings,
@ -32,6 +35,7 @@ class MangaListMapper @Inject constructor(
private val historyRepository: HistoryRepository, private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository, private val favouritesRepository: FavouritesRepository,
private val localMangaIndex: LocalMangaIndex, private val localMangaIndex: LocalMangaIndex,
private val dataRepository: MangaDataRepository,
) { ) {
private val dict by lazy { readTagsDict(context) } private val dict by lazy { readTagsDict(context) }
@ -40,9 +44,13 @@ class MangaListMapper @Inject constructor(
manga: Collection<Manga>, manga: Collection<Manga>,
mode: ListMode, mode: ListMode,
@Flags flags: Int = DEFAULTS, @Flags flags: Int = DEFAULTS,
): List<MangaListModel> { ): List<MangaListModel> = ArrayList<MangaListModel>(manga.size).apply {
val options = getOptions(flags) toListModelList(
return manga.map { toListModelImpl(it, mode, options) } destination = this,
manga = manga,
mode = mode,
flags = flags,
)
} }
suspend fun toListModelList( suspend fun toListModelList(
@ -52,8 +60,9 @@ class MangaListMapper @Inject constructor(
@Flags flags: Int = DEFAULTS, @Flags flags: Int = DEFAULTS,
) { ) {
val options = getOptions(flags) val options = getOptions(flags)
val overrides = dataRepository.getOverrides()
manga.mapTo(destination) { manga.mapTo(destination) {
toListModelImpl(it, mode, options) toListModelImpl(it, mode, options, overrides[it.id])
} }
} }
@ -61,7 +70,12 @@ class MangaListMapper @Inject constructor(
manga: Manga, manga: Manga,
mode: ListMode, mode: ListMode,
@Flags flags: Int = DEFAULTS, @Flags flags: Int = DEFAULTS,
): MangaListModel = toListModelImpl(manga, mode, getOptions(flags)) ): MangaListModel = toListModelImpl(
manga = manga,
mode = mode,
options = getOptions(flags),
override = dataRepository.getOverride(manga.id),
)
fun mapTags(tags: Collection<MangaTag>) = tags.map { fun mapTags(tags: Collection<MangaTag>) = tags.map {
ChipsView.ChipModel( ChipsView.ChipModel(
@ -71,20 +85,28 @@ class MangaListMapper @Inject constructor(
) )
} }
private suspend fun toCompactListModel(manga: Manga, @Options options: Int) = MangaCompactListModel( private suspend fun toCompactListModel(
manga: Manga,
@Options options: Int,
override: MangaOverride?,
) = MangaCompactListModel(
id = manga.id, id = manga.id,
title = manga.title, title = override?.title.ifNullOrEmpty { manga.title },
subtitle = manga.tags.joinToString(", ") { it.title }, subtitle = manga.tags.joinToString(", ") { it.title },
coverUrl = manga.coverUrl, coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
manga = manga, manga = manga,
counter = getCounter(manga.id, options), counter = getCounter(manga.id, options),
) )
private suspend fun toDetailedListModel(manga: Manga, @Options options: Int) = MangaDetailedListModel( private suspend fun toDetailedListModel(
manga: Manga,
@Options options: Int,
override: MangaOverride?,
) = MangaDetailedListModel(
id = manga.id, id = manga.id,
title = manga.title, title = override?.title.ifNullOrEmpty { manga.title },
subtitle = manga.altTitle, subtitle = manga.altTitles.firstOrNull(),
coverUrl = manga.coverUrl, coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
manga = manga, manga = manga,
counter = getCounter(manga.id, options), counter = getCounter(manga.id, options),
progress = getProgress(manga.id, options), progress = getProgress(manga.id, options),
@ -93,10 +115,14 @@ class MangaListMapper @Inject constructor(
tags = mapTags(manga.tags), tags = mapTags(manga.tags),
) )
private suspend fun toGridModel(manga: Manga, @Options options: Int) = MangaGridModel( private suspend fun toGridModel(
manga: Manga,
@Options options: Int,
override: MangaOverride?
) = MangaGridModel(
id = manga.id, id = manga.id,
title = manga.title, title = override?.title.ifNullOrEmpty { manga.title },
coverUrl = manga.coverUrl, coverUrl = override?.coverUrl.ifNullOrEmpty { manga.coverUrl },
manga = manga, manga = manga,
counter = getCounter(manga.id, options), counter = getCounter(manga.id, options),
progress = getProgress(manga.id, options), progress = getProgress(manga.id, options),
@ -107,11 +133,12 @@ class MangaListMapper @Inject constructor(
private suspend fun toListModelImpl( private suspend fun toListModelImpl(
manga: Manga, manga: Manga,
mode: ListMode, mode: ListMode,
@Options options: Int @Options options: Int,
override: MangaOverride?,
): MangaListModel = when (mode) { ): MangaListModel = when (mode) {
ListMode.LIST -> toCompactListModel(manga, options) ListMode.LIST -> toCompactListModel(manga, options, override)
ListMode.DETAILED_LIST -> toDetailedListModel(manga, options) ListMode.DETAILED_LIST -> toDetailedListModel(manga, options, override)
ListMode.GRID -> toGridModel(manga, options) ListMode.GRID -> toGridModel(manga, options, override)
} }
private suspend fun getCounter(mangaId: Long, @Options options: Int): Int { private suspend fun getCounter(mangaId: Long, @Options options: Int): Int {

@ -275,8 +275,10 @@ abstract class MangaListFragment :
@CallSuper @CallSuper
override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean { override fun onPrepareActionMode(controller: ListSelectionController, mode: ActionMode?, menu: Menu): Boolean {
val hasNoLocal = selectedItems.none { it.isLocal } val hasNoLocal = selectedItems.none { it.isLocal }
val isSingleSelection = controller.count == 1
menu.findItem(R.id.action_save)?.isVisible = hasNoLocal menu.findItem(R.id.action_save)?.isVisible = hasNoLocal
menu.findItem(R.id.action_fix)?.isVisible = hasNoLocal menu.findItem(R.id.action_fix)?.isVisible = hasNoLocal
menu.findItem(R.id.action_edit_override)?.isVisible = isSingleSelection
return super.onPrepareActionMode(controller, mode, menu) return super.onPrepareActionMode(controller, mode, menu)
} }
@ -316,6 +318,12 @@ abstract class MangaListFragment :
true true
} }
R.id.action_edit_override -> {
router.openMangaOverrideConfig(selectedItems.singleOrNull() ?: return false)
mode?.finish()
true
}
R.id.action_fix -> { R.id.action_fix -> {
val itemsSnapshot = selectedItemsIds val itemsSnapshot = selectedItemsIds
buildAlertDialog(context ?: return false, isCentered = true) { buildAlertDialog(context ?: return false, isCentered = true) {

@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
@ -17,14 +18,13 @@ import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
abstract class MangaListViewModel( abstract class MangaListViewModel(
private val settings: AppSettings, private val settings: AppSettings,
private val downloadScheduler: DownloadWorker.Scheduler, private val mangaDataRepository: MangaDataRepository,
) : BaseViewModel() { ) : BaseViewModel() {
abstract val content: StateFlow<List<ListModel>> abstract val content: StateFlow<List<ListModel>>
@ -62,13 +62,14 @@ abstract class MangaListViewModel(
protected fun observeListModeWithTriggers(): Flow<ListMode> = combine( protected fun observeListModeWithTriggers(): Flow<ListMode> = combine(
listMode, listMode,
mangaDataRepository.observeOverridesTrigger(emitInitialState = true),
settings.observe().filter { key -> settings.observe().filter { key ->
key == AppSettings.KEY_PROGRESS_INDICATORS key == AppSettings.KEY_PROGRESS_INDICATORS
|| key == AppSettings.KEY_TRACKER_ENABLED || key == AppSettings.KEY_TRACKER_ENABLED
|| key == AppSettings.KEY_QUICK_FILTER || key == AppSettings.KEY_QUICK_FILTER
|| key == AppSettings.KEY_MANGA_LIST_BADGES || key == AppSettings.KEY_MANGA_LIST_BADGES
}.onStart { emit("") }, }.onStart { emit("") },
) { mode, _ -> ) { mode, _, _ ->
mode mode
} }
} }

@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
@ -13,7 +14,6 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.core.util.ext.toFileOrNull
import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.FilterCoordinator
@ -36,22 +36,22 @@ class LocalListViewModel @Inject constructor(
mangaRepositoryFactory: MangaRepository.Factory, mangaRepositoryFactory: MangaRepository.Factory,
filterCoordinator: FilterCoordinator, filterCoordinator: FilterCoordinator,
private val settings: AppSettings, private val settings: AppSettings,
downloadScheduler: DownloadWorker.Scheduler,
mangaListMapper: MangaListMapper, mangaListMapper: MangaListMapper,
private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase, private val deleteLocalMangaUseCase: DeleteLocalMangaUseCase,
exploreRepository: ExploreRepository, exploreRepository: ExploreRepository,
@LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>, @LocalStorageChanges private val localStorageChanges: SharedFlow<LocalManga?>,
private val localStorageManager: LocalStorageManager, private val localStorageManager: LocalStorageManager,
sourcesRepository: MangaSourcesRepository, sourcesRepository: MangaSourcesRepository,
mangaDataRepository: MangaDataRepository,
) : RemoteListViewModel( ) : RemoteListViewModel(
savedStateHandle, savedStateHandle = savedStateHandle,
mangaRepositoryFactory, mangaRepositoryFactory = mangaRepositoryFactory,
filterCoordinator, filterCoordinator = filterCoordinator,
settings, settings = settings,
mangaListMapper, mangaListMapper = mangaListMapper,
downloadScheduler, exploreRepository = exploreRepository,
exploreRepository, sourcesRepository = sourcesRepository,
sourcesRepository, mangaDataRepository = mangaDataRepository,
), SharedPreferences.OnSharedPreferenceChangeListener { ), SharedPreferences.OnSharedPreferenceChangeListener {
val onMangaRemoved = MutableEventFlow<Unit>() val onMangaRemoved = MutableEventFlow<Unit>()

@ -106,7 +106,7 @@ class ReaderViewModel @Inject constructor(
init { init {
selectedBranch.value = savedStateHandle.get<String>(ReaderIntent.EXTRA_BRANCH) selectedBranch.value = savedStateHandle.get<String>(ReaderIntent.EXTRA_BRANCH)
readingState.value = savedStateHandle[ReaderIntent.EXTRA_STATE] readingState.value = savedStateHandle[ReaderIntent.EXTRA_STATE]
mangaDetails.value = intent.manga?.let { MangaDetails(it, null, null, false) } mangaDetails.value = intent.manga?.let { MangaDetails(it, null, null, null, false) }
} }
val readerMode = MutableStateFlow<ReaderMode?>(null) val readerMode = MutableStateFlow<ReaderMode?>(null)

@ -20,6 +20,7 @@ import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.distinctById import org.koitharu.kotatsu.core.model.distinctById
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
@ -27,7 +28,6 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.getCauseUrl import org.koitharu.kotatsu.core.util.ext.getCauseUrl
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.FilterCoordinator
@ -53,10 +53,10 @@ open class RemoteListViewModel @Inject constructor(
final override val filterCoordinator: FilterCoordinator, final override val filterCoordinator: FilterCoordinator,
settings: AppSettings, settings: AppSettings,
protected val mangaListMapper: MangaListMapper, protected val mangaListMapper: MangaListMapper,
downloadScheduler: DownloadWorker.Scheduler,
private val exploreRepository: ExploreRepository, private val exploreRepository: ExploreRepository,
sourcesRepository: MangaSourcesRepository, sourcesRepository: MangaSourcesRepository,
) : MangaListViewModel(settings, downloadScheduler), FilterCoordinator.Owner { mangaDataRepository: MangaDataRepository
) : MangaListViewModel(settings, mangaDataRepository), FilterCoordinator.Owner {
val source = MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE]) val source = MangaSource(savedStateHandle[RemoteListFragment.ARG_SOURCE])
val isRandomLoading = MutableStateFlow(false) val isRandomLoading = MutableStateFlow(false)

@ -0,0 +1,139 @@
package org.koitharu.kotatsu.settings.override
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia
import androidx.activity.viewModels
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import coil3.ImageLoader
import coil3.request.ImageRequest
import coil3.request.lifecycle
import coil3.request.target
import coil3.size.Scale
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.filterNotNull
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.model.MangaOverride
import org.koitharu.kotatsu.core.util.ext.consumeAll
import org.koitharu.kotatsu.core.util.ext.crossfade
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getThemeColor
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.databinding.ActivityOverrideEditBinding
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
import javax.inject.Inject
import androidx.appcompat.R as appcompatR
import com.google.android.material.R as materialR
@AndroidEntryPoint
class OverrideConfigActivity : BaseActivity<ActivityOverrideEditBinding>(), View.OnClickListener {
private val viewModel: OverrideConfigViewModel by viewModels()
private val pickCoverFileLauncher = registerForActivityResult(
PickVisualMedia(),
) { uri ->
if (uri != null) {
contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
viewModel.updateCover(uri.toString())
}
}
@Inject
lateinit var coil: ImageLoader
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(ActivityOverrideEditBinding.inflate(layoutInflater))
setDisplayHomeAsUp(isEnabled = true, showUpAsClose = true)
viewBinding.buttonDone.setOnClickListener(this)
viewBinding.buttonPickFile.setOnClickListener(this)
viewBinding.buttonPickPage.setOnClickListener(this)
viewBinding.buttonResetCover.setOnClickListener(this)
viewBinding.layoutName.setEndIconOnClickListener(this)
viewModel.data.filterNotNull().observe(this, ::onDataChanged)
viewModel.onSaved.observeEvent(this) { finishAfterTransition() }
viewModel.isLoading.observe(this, ::onLoadingStateChanged)
viewModel.onError.observeEvent(this, ::onError)
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val typeMask = WindowInsetsCompat.Type.systemBars()
val barsInsets = insets.getInsets(typeMask)
viewBinding.root.setPadding(
barsInsets.left,
barsInsets.top,
barsInsets.right,
barsInsets.bottom,
)
return insets.consumeAll(typeMask)
}
override fun onClick(v: View) {
when (v.id) {
R.id.button_done -> viewModel.save(
title = viewBinding.editName.text?.toString()?.trim(),
)
materialR.id.text_input_end_icon -> viewBinding.editName.text?.clear()
R.id.button_reset_cover -> viewModel.updateCover(null)
R.id.button_pick_file -> {
val request = PickVisualMediaRequest.Builder()
.setMediaType(PickVisualMedia.ImageOnly)
.setAccentColor(getThemeColor(appcompatR.attr.colorAccent).toLong())
.build()
if (!pickCoverFileLauncher.tryLaunch(request)) {
Snackbar.make(
viewBinding.imageViewCover,
R.string.operation_not_supported,
Snackbar.LENGTH_SHORT,
).show()
}
}
}
}
private fun onDataChanged(data: Pair<Manga, MangaOverride>) {
val (manga, override) = data
ImageRequest.Builder(this)
.target(viewBinding.imageViewCover)
.size(CoverSizeResolver(viewBinding.imageViewCover))
.scale(Scale.FILL)
.data(override.coverUrl.ifNullOrEmpty { manga.coverUrl })
.mangaSourceExtra(manga.source)
.crossfade(this)
.lifecycle(this)
.enqueueWith(coil)
viewBinding.layoutName.placeholderText = manga.title
if (viewBinding.editName.tag == null) {
viewBinding.editName.setText(override.title)
viewBinding.editName.tag = override.title
}
viewBinding.buttonResetCover.isEnabled = !override.coverUrl.isNullOrEmpty()
}
private fun onError(e: Throwable) {
viewBinding.textViewError.text = e.getDisplayMessage(resources)
viewBinding.textViewError.isVisible = true
}
private fun onLoadingStateChanged(isLoading: Boolean) {
viewBinding.buttonDone.isEnabled = !isLoading
viewBinding.editName.isEnabled = !isLoading
if (isLoading) {
viewBinding.textViewError.isVisible = false
}
}
}

@ -0,0 +1,53 @@
package org.koitharu.kotatsu.settings.override
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.model.MangaOverride
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.parsers.model.Manga
import javax.inject.Inject
@HiltViewModel
class OverrideConfigViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val dataRepository: MangaDataRepository,
) : BaseViewModel() {
private val manga = savedStateHandle.require<ParcelableManga>(AppRouter.KEY_MANGA).manga
val data = MutableStateFlow<Pair<Manga, MangaOverride>?>(null)
val onSaved = MutableEventFlow<Unit>()
init {
launchLoadingJob(Dispatchers.Default) {
data.value = manga to (dataRepository.getOverride(manga.id) ?: emptyOverride())
}
}
fun save(title: String?) {
launchLoadingJob(Dispatchers.Default) {
val override = checkNotNull(data.value).second.copy(
title = title,
)
dataRepository.setOverride(manga.id, override)
onSaved.call(Unit)
}
}
fun updateCover(coverUri: String?) {
val snapshot = data.value ?: return
data.value = snapshot.first to snapshot.second.copy(
coverUrl = coverUri,
)
}
private fun emptyOverride() = MangaOverride(null, null, null)
}

@ -11,10 +11,10 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.util.ext.onFirst import org.koitharu.kotatsu.core.util.ext.onFirst
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.domain.QuickFilterListener import org.koitharu.kotatsu.list.domain.QuickFilterListener
import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.MangaListViewModel
@ -30,10 +30,10 @@ class SuggestionsViewModel @Inject constructor(
repository: SuggestionRepository, repository: SuggestionRepository,
settings: AppSettings, settings: AppSettings,
private val mangaListMapper: MangaListMapper, private val mangaListMapper: MangaListMapper,
downloadScheduler: DownloadWorker.Scheduler,
private val quickFilter: SuggestionsListQuickFilter, private val quickFilter: SuggestionsListQuickFilter,
private val suggestionsScheduler: SuggestionsWorker.Scheduler, private val suggestionsScheduler: SuggestionsWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler), QuickFilterListener by quickFilter { mangaDataRepository: MangaDataRepository,
) : MangaListViewModel(settings, mangaDataRepository), QuickFilterListener by quickFilter {
override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_SUGGESTIONS) { suggestionsListMode } override val listMode = settings.observeAsFlow(AppSettings.KEY_LIST_MODE_SUGGESTIONS) { suggestionsListMode }
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.suggestionsListMode) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.suggestionsListMode)

@ -11,13 +11,13 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo
import org.koitharu.kotatsu.core.util.ext.onFirst import org.koitharu.kotatsu.core.util.ext.onFirst
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.domain.QuickFilterListener import org.koitharu.kotatsu.list.domain.QuickFilterListener
@ -38,8 +38,8 @@ class UpdatesViewModel @Inject constructor(
settings: AppSettings, settings: AppSettings,
private val mangaListMapper: MangaListMapper, private val mangaListMapper: MangaListMapper,
private val quickFilter: UpdatesListQuickFilter, private val quickFilter: UpdatesListQuickFilter,
downloadScheduler: DownloadWorker.Scheduler, mangaDataRepository: MangaDataRepository,
) : MangaListViewModel(settings, downloadScheduler), QuickFilterListener by quickFilter { ) : MangaListViewModel(settings, mangaDataRepository), QuickFilterListener by quickFilter {
override val content = combine( override val content = combine(
quickFilter.appliedOptions.flatMapLatest { filterOptions -> quickFilter.appliedOptions.flatMapLatest { filterOptions ->

@ -0,0 +1,13 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M13.5,7A6.5,6.5 0 0,1 20,13.5A6.5,6.5 0 0,1 13.5,20H10V18H13.5C16,18 18,16 18,13.5C18,11 16,9 13.5,9H7.83L10.91,12.09L9.5,13.5L4,8L9.5,2.5L10.92,3.91L7.83,7H13.5M6,18H8V20H6V18Z" />
</vector>

@ -0,0 +1,177 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize">
<Button
android:id="@+id/button_done"
style="@style/Widget.Material3.Button.UnelevatedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginHorizontal="@dimen/toolbar_button_margin"
android:text="@string/save" />
</com.google.android.material.appbar.MaterialToolbar>
<ScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:overScrollMode="ifContentScrolls">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingBottom="@dimen/screen_padding">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_cover"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:background="?colorSecondaryContainer"
android:clipToOutline="true"
android:foreground="?selectableItemBackground"
android:scaleType="centerCrop"
app:layout_constraintDimensionRatio="H,13:18"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent="0.3"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover"
tools:background="@tools:sample/backgrounds/scenic[5]"
tools:ignore="ContentDescription,UnusedAttribute" />
<TextView
android:id="@+id/textView_cover_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/screen_padding"
android:paddingHorizontal="@dimen/margin_small"
android:text="@string/change_cover"
android:textAppearance="?textAppearanceTitleSmall"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toTopOf="@id/imageView_cover" />
<org.koitharu.kotatsu.core.ui.widgets.ListItemTextView
android:id="@+id/button_pick_file"
android:layout_width="0dp"
android:layout_height="?android:listPreferredItemHeightSmall"
android:layout_marginTop="4dp"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:text="@string/pick_custom_file"
android:textAppearance="?attr/textAppearanceButton"
app:drawableStartCompat="@drawable/ic_folder_file"
app:layout_constraintEnd_toEndOf="@id/textView_cover_title"
app:layout_constraintStart_toStartOf="@id/textView_cover_title"
app:layout_constraintTop_toBottomOf="@id/textView_cover_title" />
<org.koitharu.kotatsu.core.ui.widgets.ListItemTextView
android:id="@+id/button_pick_page"
android:layout_width="0dp"
android:layout_height="?android:listPreferredItemHeightSmall"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:text="@string/pick_manga_page"
android:textAppearance="?attr/textAppearanceButton"
android:visibility="gone"
app:drawableStartCompat="@drawable/ic_grid"
app:layout_constraintEnd_toEndOf="@id/textView_cover_title"
app:layout_constraintStart_toStartOf="@id/textView_cover_title"
app:layout_constraintTop_toBottomOf="@id/button_pick_file" />
<org.koitharu.kotatsu.core.ui.widgets.ListItemTextView
android:id="@+id/button_reset_cover"
android:layout_width="0dp"
android:layout_height="?android:listPreferredItemHeightSmall"
android:drawablePadding="?android:listPreferredItemPaddingStart"
android:paddingStart="?android:listPreferredItemPaddingStart"
android:paddingEnd="?android:listPreferredItemPaddingEnd"
android:text="@string/use_default_cover"
android:textAppearance="?attr/textAppearanceButton"
app:drawableStartCompat="@drawable/ic_revert"
app:layout_constraintEnd_toEndOf="@id/textView_cover_title"
app:layout_constraintStart_toStartOf="@id/textView_cover_title"
app:layout_constraintTop_toBottomOf="@id/button_pick_page" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_cover"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:constraint_referenced_ids="imageView_cover,button_reset_cover" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/layout_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/screen_padding"
android:layout_marginTop="@dimen/margin_normal"
app:endIconContentDescription="@string/reset"
app:endIconDrawable="@drawable/ic_revert"
app:endIconMode="custom"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/barrier_cover">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/name"
android:imeOptions="actionDone"
android:inputType="textCapSentences"
android:maxLength="120"
tools:text="@tools:sample/lorem[3]" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/textView_tip"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_normal"
android:text="@string/manga_override_hint"
android:textAppearance="?attr/textAppearanceBodySmall"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/layout_name" />
<TextView
android:id="@+id/textView_error"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/screen_padding"
android:layout_marginTop="@dimen/margin_small"
android:textColor="?colorError"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/textView_tip"
tools:text="@tools:sample/lorem[4]"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</LinearLayout>

@ -33,6 +33,12 @@
android:title="@string/categories" android:title="@string/categories"
app:showAsAction="ifRoom|withText" /> app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_edit_override"
android:icon="@drawable/ic_edit"
android:title="@string/edit"
app:showAsAction="ifRoom|withText" />
<item <item
android:id="@+id/action_mark_current" android:id="@+id/action_mark_current"
android:icon="@drawable/ic_eye_check" android:icon="@drawable/ic_eye_check"

@ -33,6 +33,12 @@
android:title="@string/fix" android:title="@string/fix"
app:showAsAction="ifRoom|withText" /> app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_edit_override"
android:icon="@drawable/ic_edit"
android:title="@string/edit"
app:showAsAction="ifRoom|withText" />
<item <item
android:id="@+id/action_mark_current" android:id="@+id/action_mark_current"
android:icon="@drawable/ic_eye_check" android:icon="@drawable/ic_eye_check"

@ -15,6 +15,12 @@
android:title="@string/delete" android:title="@string/delete"
app:showAsAction="ifRoom|withText" /> app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_edit_override"
android:icon="@drawable/ic_edit"
android:title="@string/edit"
app:showAsAction="ifRoom|withText" />
<item <item
android:id="@+id/action_select_all" android:id="@+id/action_select_all"
android:icon="?actionModeSelectAllDrawable" android:icon="?actionModeSelectAllDrawable"

@ -826,4 +826,9 @@
<string name="tags_warnings">Highlight dangerous genres</string> <string name="tags_warnings">Highlight dangerous genres</string>
<string name="tags_warnings_summary">Highlight genres that may be inappropriate for most users</string> <string name="tags_warnings_summary">Highlight genres that may be inappropriate for most users</string>
<string name="error_non_file_uri">The selected path cannot be used because it does not denote a file or directory</string> <string name="error_non_file_uri">The selected path cannot be used because it does not denote a file or directory</string>
<string name="manga_override_hint">These changes will affect how manga is displayed in the app</string>
<string name="use_default_cover">Use default cover</string>
<string name="pick_manga_page">Pick manga page</string>
<string name="pick_custom_file">Pick custom file</string>
<string name="change_cover">Change cover</string>
</resources> </resources>

Loading…
Cancel
Save