Improve quick filters
This commit is contained in:
@@ -43,6 +43,8 @@ fun MangaSource(name: String?): MangaSource {
|
||||
return UnknownMangaSource
|
||||
}
|
||||
|
||||
fun Collection<String>.toMangaSources() = map(::MangaSource)
|
||||
|
||||
fun MangaSource.isNsfw(): Boolean = when (this) {
|
||||
is MangaSourceInfo -> mangaSource.isNsfw()
|
||||
is MangaParserSource -> contentType == ContentType.HENTAI
|
||||
@@ -61,11 +63,16 @@ val ContentType.titleResId
|
||||
ContentType.NOVEL -> R.string.content_type_novel
|
||||
}
|
||||
|
||||
fun MangaSource.getSummary(context: Context): String? = when (this) {
|
||||
is MangaSourceInfo -> mangaSource.getSummary(context)
|
||||
tailrec fun MangaSource.unwrap(): MangaSource = if (this is MangaSourceInfo) {
|
||||
mangaSource.unwrap()
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
fun MangaSource.getSummary(context: Context): String? = when (val source = unwrap()) {
|
||||
is MangaParserSource -> {
|
||||
val type = context.getString(contentType.titleResId)
|
||||
val locale = locale.toLocale().getDisplayName(context)
|
||||
val type = context.getString(source.contentType.titleResId)
|
||||
val locale = source.locale.toLocale().getDisplayName(context)
|
||||
context.getString(R.string.source_summary_pattern, type, locale)
|
||||
}
|
||||
|
||||
@@ -74,11 +81,10 @@ fun MangaSource.getSummary(context: Context): String? = when (this) {
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun MangaSource.getTitle(context: Context): String = when (this) {
|
||||
is MangaSourceInfo -> mangaSource.getTitle(context)
|
||||
is MangaParserSource -> title
|
||||
fun MangaSource.getTitle(context: Context): String = when (val source = unwrap()) {
|
||||
is MangaParserSource -> source.title
|
||||
LocalMangaSource -> context.getString(R.string.local_storage)
|
||||
is ExternalMangaSource -> resolveName(context)
|
||||
is ExternalMangaSource -> source.resolveName(context)
|
||||
else -> context.getString(R.string.unknown)
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ class FaviconFetcher(
|
||||
.url(url)
|
||||
.get()
|
||||
.tag(MangaSource::class.java, source)
|
||||
request.tag(MangaSource::class.java, source)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
options.tags.asMap().forEach { request.tag(it.key as Class<Any>, it.value) }
|
||||
val response = okHttpClient.newCall(request.build()).await()
|
||||
|
||||
@@ -8,18 +8,32 @@ import androidx.annotation.ColorRes
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.view.children
|
||||
import coil.ImageLoader
|
||||
import coil.request.Disposable
|
||||
import coil.request.ImageRequest
|
||||
import coil.transform.RoundedCornersTransformation
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.chip.ChipDrawable
|
||||
import com.google.android.material.chip.ChipGroup
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.ui.image.ChipIconTarget
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.setProgressIcon
|
||||
import org.koitharu.kotatsu.parsers.util.ifZero
|
||||
import javax.inject.Inject
|
||||
import com.google.android.material.R as materialR
|
||||
|
||||
@AndroidEntryPoint
|
||||
class ChipsView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = com.google.android.material.R.attr.chipGroupStyle,
|
||||
) : ChipGroup(context, attrs, defStyleAttr) {
|
||||
|
||||
@Inject
|
||||
lateinit var coil: ImageLoader
|
||||
|
||||
private var isLayoutSuppressedCompat = false
|
||||
private var isLayoutCalledOnSuppressed = false
|
||||
private val chipOnClickListener = InternalChipClickListener()
|
||||
@@ -90,8 +104,10 @@ class ChipsView @JvmOverloads constructor(
|
||||
val title: CharSequence? = null,
|
||||
@StringRes val titleResId: Int = 0,
|
||||
@DrawableRes val icon: Int = 0,
|
||||
val iconData: Any? = null,
|
||||
@ColorRes val tint: Int = 0,
|
||||
val isChecked: Boolean = false,
|
||||
val isLoading: Boolean = false,
|
||||
val isDropdown: Boolean = false,
|
||||
val isCloseable: Boolean = false,
|
||||
val data: Any? = null,
|
||||
@@ -100,6 +116,7 @@ class ChipsView @JvmOverloads constructor(
|
||||
private inner class DataChip(context: Context) : Chip(context) {
|
||||
|
||||
private var model: ChipModel? = null
|
||||
private var imageRequest: Disposable? = null
|
||||
|
||||
init {
|
||||
val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle)
|
||||
@@ -112,6 +129,9 @@ class ChipsView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
fun bind(model: ChipModel) {
|
||||
if (this.model == model) {
|
||||
return
|
||||
}
|
||||
this.model = model
|
||||
|
||||
if (model.titleResId == 0) {
|
||||
@@ -127,13 +147,7 @@ class ChipsView @JvmOverloads constructor(
|
||||
isChecked = false
|
||||
isCheckable = false
|
||||
}
|
||||
if (model.icon == 0 || model.isChecked) {
|
||||
chipIcon = null
|
||||
isChipIconVisible = false
|
||||
} else {
|
||||
setChipIconResource(model.icon)
|
||||
isChipIconVisible = true
|
||||
}
|
||||
bindIcon(model)
|
||||
isCheckedIconVisible = model.isChecked
|
||||
isCloseIconVisible = if (model.isCloseable || model.isDropdown) {
|
||||
setCloseIconResource(
|
||||
@@ -147,6 +161,54 @@ class ChipsView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
override fun toggle() = Unit
|
||||
|
||||
private fun bindIcon(model: ChipModel) {
|
||||
when {
|
||||
model.isChecked -> {
|
||||
imageRequest?.dispose()
|
||||
imageRequest = null
|
||||
chipIcon = null
|
||||
isChipIconVisible = false
|
||||
}
|
||||
|
||||
model.isLoading -> {
|
||||
imageRequest?.dispose()
|
||||
imageRequest = null
|
||||
isChipIconVisible = true
|
||||
setProgressIcon()
|
||||
}
|
||||
|
||||
model.iconData != null -> {
|
||||
val placeholder = model.icon.ifZero { materialR.drawable.navigation_empty_icon }
|
||||
imageRequest = ImageRequest.Builder(context)
|
||||
.data(model.iconData)
|
||||
.crossfade(false)
|
||||
.size(resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size))
|
||||
.target(ChipIconTarget(this))
|
||||
.placeholder(placeholder)
|
||||
.fallback(placeholder)
|
||||
.error(placeholder)
|
||||
.transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.chip_icon_corner)))
|
||||
.allowRgb565(true)
|
||||
.enqueueWith(coil)
|
||||
isChipIconVisible = true
|
||||
}
|
||||
|
||||
model.icon != 0 -> {
|
||||
imageRequest?.dispose()
|
||||
imageRequest = null
|
||||
setChipIconResource(model.icon)
|
||||
isChipIconVisible = true
|
||||
}
|
||||
|
||||
else -> {
|
||||
imageRequest?.dispose()
|
||||
imageRequest = null
|
||||
chipIcon = null
|
||||
isChipIconVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class InternalChipClickListener : OnClickListener {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.koitharu.kotatsu.core.util.ext
|
||||
|
||||
import android.content.Context
|
||||
import android.database.DatabaseUtils
|
||||
import androidx.annotation.FloatRange
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.parsers.util.ellipsize
|
||||
@@ -64,3 +65,11 @@ fun <T> Collection<T>.joinToStringWithLimit(context: Context, limit: Int, transf
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("",
|
||||
ReplaceWith(
|
||||
"sqlEscapeString(this)",
|
||||
"android.database.DatabaseUtils.sqlEscapeString"
|
||||
)
|
||||
)
|
||||
fun String.sqlEscape(): String = DatabaseUtils.sqlEscapeString(this)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.favourites.data
|
||||
|
||||
import android.database.DatabaseUtils.sqlEscapeString
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
@@ -120,6 +121,12 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback {
|
||||
@Query("SELECT COUNT(category_id) FROM favourites WHERE manga_id = :mangaId AND deleted_at = 0")
|
||||
abstract suspend fun findCategoriesCount(mangaId: Long): Int
|
||||
|
||||
@Query("SELECT manga.source AS count FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit")
|
||||
abstract suspend fun findPopularSources(limit: Int): List<String>
|
||||
|
||||
@Query("SELECT manga.source AS count FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.category_id = :categoryId GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit")
|
||||
abstract suspend fun findPopularSources(categoryId: Long, limit: Int): List<String>
|
||||
|
||||
/** INSERT **/
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
@@ -200,6 +207,7 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback {
|
||||
ListFilterOption.Macro.NSFW -> "manga.nsfw = 1"
|
||||
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE favourites.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})"
|
||||
ListFilterOption.Downloaded -> "EXISTS(SELECT * FROM local_index WHERE local_index.manga_id = favourites.manga_id)"
|
||||
is ListFilterOption.Source -> "manga.source = ${sqlEscapeString(option.mangaSource.name)}"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package org.koitharu.kotatsu.favourites.domain
|
||||
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import org.koitharu.kotatsu.core.os.NetworkState
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.domain.MangaListQuickFilter
|
||||
import javax.inject.Inject
|
||||
|
||||
class FavoritesListQuickFilter @Inject constructor(
|
||||
class FavoritesListQuickFilter @AssistedInject constructor(
|
||||
@Assisted private val categoryId: Long,
|
||||
private val settings: AppSettings,
|
||||
private val repository: FavouritesRepository,
|
||||
networkState: NetworkState,
|
||||
@@ -22,5 +25,14 @@ class FavoritesListQuickFilter @Inject constructor(
|
||||
add(ListFilterOption.Macro.NEW_CHAPTERS)
|
||||
}
|
||||
add(ListFilterOption.Macro.COMPLETED)
|
||||
repository.findPopularSources(categoryId, 3).mapTo(this) {
|
||||
ListFilterOption.Source(it)
|
||||
}
|
||||
}
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
||||
fun create(categoryId: Long): FavoritesListQuickFilter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ 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.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.toMangaSources
|
||||
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
|
||||
import org.koitharu.kotatsu.core.util.ext.mapItems
|
||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||
@@ -22,6 +24,7 @@ import org.koitharu.kotatsu.favourites.domain.model.Cover
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import javax.inject.Inject
|
||||
|
||||
@Reusable
|
||||
@@ -136,6 +139,16 @@ class FavouritesRepository @Inject constructor(
|
||||
return db.getFavouritesDao().findCategoriesIds(mangaId).toSet()
|
||||
}
|
||||
|
||||
suspend fun findPopularSources(categoryId: Long, limit: Int): List<MangaSource> {
|
||||
return db.getFavouritesDao().run {
|
||||
if (categoryId == 0L) {
|
||||
findPopularSources(limit)
|
||||
} else {
|
||||
findPopularSources(categoryId, limit)
|
||||
}
|
||||
}.toMangaSources()
|
||||
}
|
||||
|
||||
suspend fun createCategory(
|
||||
title: String,
|
||||
sortOrder: ListSortOrder,
|
||||
|
||||
@@ -6,11 +6,13 @@ import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.view.View
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.widget.ImageViewCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import coil.ImageLoader
|
||||
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
|
||||
import org.koitharu.kotatsu.core.util.ext.enqueueWith
|
||||
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
||||
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||
@@ -64,14 +66,20 @@ fun categoriesHeaderAD(
|
||||
|
||||
repeat(coverViews.size) { i ->
|
||||
val cover = item.covers.getOrNull(i)
|
||||
coverViews[i].newImageRequest(lifecycleOwner, cover?.url)?.run {
|
||||
placeholder(R.drawable.ic_placeholder)
|
||||
fallback(fallback)
|
||||
source(cover?.mangaSource)
|
||||
crossfade(crossFadeDuration * (i + 1))
|
||||
error(R.drawable.ic_error_placeholder)
|
||||
allowRgb565(true)
|
||||
enqueueWith(coil)
|
||||
val view = coverViews[i]
|
||||
view.isVisible = cover != null
|
||||
if (cover == null) {
|
||||
view.disposeImageRequest()
|
||||
} else {
|
||||
view.newImageRequest(lifecycleOwner, cover.url)?.run {
|
||||
placeholder(R.drawable.ic_placeholder)
|
||||
fallback(fallback)
|
||||
source(cover.mangaSource)
|
||||
crossfade(crossFadeDuration * (i + 1))
|
||||
error(R.drawable.ic_error_placeholder)
|
||||
allowRgb565(true)
|
||||
enqueueWith(coil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,12 +49,13 @@ class FavouritesListViewModel @Inject constructor(
|
||||
private val repository: FavouritesRepository,
|
||||
private val mangaListMapper: MangaListMapper,
|
||||
private val markAsReadUseCase: MarkAsReadUseCase,
|
||||
private val quickFilter: FavoritesListQuickFilter,
|
||||
quickFilterFactory: FavoritesListQuickFilter.Factory,
|
||||
settings: AppSettings,
|
||||
downloadScheduler: DownloadWorker.Scheduler,
|
||||
) : MangaListViewModel(settings, downloadScheduler), QuickFilterListener by quickFilter {
|
||||
) : MangaListViewModel(settings, downloadScheduler), QuickFilterListener {
|
||||
|
||||
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
|
||||
private val quickFilter = quickFilterFactory.create(categoryId)
|
||||
private val refreshTrigger = MutableStateFlow(Any())
|
||||
private val limit = MutableStateFlow(PAGE_SIZE)
|
||||
private val isPaginationReady = AtomicBoolean(false)
|
||||
@@ -91,6 +92,12 @@ class FavouritesListViewModel @Inject constructor(
|
||||
|
||||
override fun onRetry() = Unit
|
||||
|
||||
override fun setFilterOption(option: ListFilterOption, isApplied: Boolean) = quickFilter.setFilterOption(option, isApplied)
|
||||
|
||||
override fun toggleFilterOption(option: ListFilterOption) = quickFilter.toggleFilterOption(option)
|
||||
|
||||
override fun clearFilter() = quickFilter.clearFilter()
|
||||
|
||||
fun markAsRead(items: Set<Manga>) {
|
||||
launchLoadingJob(Dispatchers.Default) {
|
||||
markAsReadUseCase(items)
|
||||
|
||||
@@ -66,7 +66,7 @@ class FilterCoordinator @Inject constructor(
|
||||
get() = repository.source
|
||||
|
||||
val isFilterApplied: Boolean
|
||||
get() = !currentListFilter.value.isEmpty()
|
||||
get() = currentListFilter.value.isNotEmpty()
|
||||
|
||||
val query: StateFlow<String?> = currentListFilter.map { it.query }
|
||||
.stateIn(coroutineScope, SharingStarted.Eagerly, null)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.history.data
|
||||
|
||||
import android.database.DatabaseUtils.sqlEscapeString
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
@@ -73,6 +74,9 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback {
|
||||
)
|
||||
abstract suspend fun findPopularTags(limit: Int): List<TagEntity>
|
||||
|
||||
@Query("SELECT manga.source AS count FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit")
|
||||
abstract suspend fun findPopularSources(limit: Int): List<String>
|
||||
|
||||
@Query("SELECT * FROM history WHERE manga_id = :id AND deleted_at = 0")
|
||||
abstract suspend fun find(id: Long): HistoryEntity?
|
||||
|
||||
@@ -160,6 +164,7 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback {
|
||||
ListFilterOption.Macro.NSFW -> "manga.nsfw = 1"
|
||||
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE history.manga_id = manga_tags.manga_id AND tag_id = ${option.tagId})"
|
||||
ListFilterOption.Downloaded -> "EXISTS(SELECT * FROM local_index WHERE local_index.manga_id = history.manga_id)"
|
||||
is ListFilterOption.Source -> "manga.source = ${sqlEscapeString(option.mangaSource.name)}"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,13 @@ import org.koitharu.kotatsu.core.db.entity.toEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
import org.koitharu.kotatsu.core.db.entity.toMangaTag
|
||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||
import org.koitharu.kotatsu.core.db.entity.toMangaTagsList
|
||||
import org.koitharu.kotatsu.core.model.MangaHistory
|
||||
import org.koitharu.kotatsu.core.model.MangaSource
|
||||
import org.koitharu.kotatsu.core.model.findById
|
||||
import org.koitharu.kotatsu.core.model.isLocal
|
||||
import org.koitharu.kotatsu.core.model.isNsfw
|
||||
import org.koitharu.kotatsu.core.model.toMangaSources
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode
|
||||
@@ -26,6 +29,7 @@ import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||
import org.koitharu.kotatsu.list.domain.ReadingProgress
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler
|
||||
import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble
|
||||
@@ -177,7 +181,11 @@ class HistoryRepository @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun getPopularTags(limit: Int): List<MangaTag> {
|
||||
return db.getHistoryDao().findPopularTags(limit).map { x -> x.toMangaTag() }
|
||||
return db.getHistoryDao().findPopularTags(limit).toMangaTagsList()
|
||||
}
|
||||
|
||||
suspend fun getPopularSources(limit: Int): List<MangaSource> {
|
||||
return db.getHistoryDao().findPopularSources(limit).toMangaSources()
|
||||
}
|
||||
|
||||
fun shouldSkip(manga: Manga): Boolean {
|
||||
|
||||
@@ -31,5 +31,8 @@ class HistoryListQuickFilter @Inject constructor(
|
||||
repository.getPopularTags(3).mapTo(this) {
|
||||
ListFilterOption.Tag(it)
|
||||
}
|
||||
repository.getPopularSources(3).mapTo(this) {
|
||||
ListFilterOption.Source(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.db.entity.toEntity
|
||||
import org.koitharu.kotatsu.core.model.FavouriteCategory
|
||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||
import org.koitharu.kotatsu.core.model.unwrap
|
||||
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
|
||||
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
|
||||
sealed interface ListFilterOption {
|
||||
@@ -19,6 +25,8 @@ sealed interface ListFilterOption {
|
||||
|
||||
val groupKey: String
|
||||
|
||||
fun getIconData(): Any? = null
|
||||
|
||||
data object Downloaded : ListFilterOption {
|
||||
|
||||
override val titleResId: Int
|
||||
@@ -88,6 +96,31 @@ sealed interface ListFilterOption {
|
||||
get() = "_favcat"
|
||||
}
|
||||
|
||||
data class Source(
|
||||
val mangaSource: MangaSource
|
||||
) : ListFilterOption {
|
||||
override val titleResId: Int
|
||||
get() = when (mangaSource.unwrap()) {
|
||||
is ExternalMangaSource -> R.string.external_source
|
||||
LocalMangaSource -> R.string.local_storage
|
||||
else -> 0
|
||||
}
|
||||
|
||||
override val iconResId: Int
|
||||
get() = R.drawable.ic_web
|
||||
|
||||
override val titleText: CharSequence?
|
||||
get() = when (val source = mangaSource.unwrap()) {
|
||||
is MangaParserSource -> source.title
|
||||
else -> null
|
||||
}
|
||||
|
||||
override val groupKey: String
|
||||
get() = "_source"
|
||||
|
||||
override fun getIconData() = mangaSource.faviconUri()
|
||||
}
|
||||
|
||||
data class Inverted(
|
||||
val option: ListFilterOption,
|
||||
override val iconResId: Int,
|
||||
|
||||
@@ -55,6 +55,7 @@ abstract class MangaListQuickFilter(
|
||||
title = option.titleText,
|
||||
titleResId = option.titleResId,
|
||||
icon = option.iconResId,
|
||||
iconData = option.getIconData(),
|
||||
isChecked = option in selectedOptions,
|
||||
data = option,
|
||||
)
|
||||
|
||||
@@ -251,10 +251,6 @@ abstract class MangaListFragment :
|
||||
resolveException(error)
|
||||
}
|
||||
|
||||
override fun onUpdateFilter(tags: Set<MangaTag>) {
|
||||
viewModel.onUpdateFilter(tags)
|
||||
}
|
||||
|
||||
private fun onGridScaleChanged(scale: Float) {
|
||||
spanSizeLookup.invalidateCache()
|
||||
spanResolver?.setGridSize(scale, requireViewBinding().recyclerView)
|
||||
|
||||
@@ -43,8 +43,6 @@ abstract class MangaListViewModel(
|
||||
val isIncognitoModeEnabled: Boolean
|
||||
get() = settings.isIncognitoModeEnabled
|
||||
|
||||
open fun onUpdateFilter(tags: Set<MangaTag>) = Unit
|
||||
|
||||
abstract fun onRefresh()
|
||||
|
||||
abstract fun onRetry()
|
||||
|
||||
@@ -2,12 +2,9 @@ package org.koitharu.kotatsu.list.ui.adapter
|
||||
|
||||
import android.view.View
|
||||
import org.koitharu.kotatsu.core.ui.widgets.TipView
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
|
||||
interface MangaListListener : MangaDetailsClickListener, ListStateHolderListener, ListHeaderClickListener,
|
||||
TipView.OnButtonClickListener, QuickFilterClickListener {
|
||||
|
||||
fun onUpdateFilter(tags: Set<MangaTag>)
|
||||
|
||||
fun onFilterClick(view: View?)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
|
||||
|
||||
@Suppress("DataClassPrivateConstructor")
|
||||
@ExposedCopyVisibility
|
||||
data class ListHeader private constructor(
|
||||
private val textRaw: Any,
|
||||
@StringRes val buttonTextRes: Int,
|
||||
|
||||
@@ -7,6 +7,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.parser.MangaDataRepository
|
||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||
@@ -30,6 +32,7 @@ class LocalMangaIndex @Inject constructor(
|
||||
) : FlowCollector<LocalManga?> {
|
||||
|
||||
private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||
private val mutex = Mutex()
|
||||
|
||||
private var previousHash: Long
|
||||
get() = prefs.getLong(KEY_HASH, 0L)
|
||||
@@ -41,7 +44,7 @@ class LocalMangaIndex @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun update(): Boolean {
|
||||
suspend fun update(): Boolean = mutex.withLock {
|
||||
val newHash = computeHash()
|
||||
if (newHash == previousHash) {
|
||||
return false
|
||||
@@ -57,7 +60,13 @@ class LocalMangaIndex @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun get(mangaId: Long): LocalManga? {
|
||||
val path = db.getLocalMangaIndexDao().findPath(mangaId) ?: return null
|
||||
var path = db.getLocalMangaIndexDao().findPath(mangaId)
|
||||
if (path == null && mutex.isLocked) { // wait for updating complete
|
||||
path = mutex.withLock { db.getLocalMangaIndexDao().findPath(mangaId) }
|
||||
}
|
||||
if (path == null) {
|
||||
return null
|
||||
}
|
||||
return runCatchingCancellable {
|
||||
LocalMangaInput.of(File(path)).getManga()
|
||||
}.onFailure {
|
||||
@@ -65,9 +74,11 @@ class LocalMangaIndex @Inject constructor(
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
suspend fun put(manga: LocalManga) = db.withTransaction {
|
||||
mangaDataRepository.storeManga(manga.manga)
|
||||
db.getLocalMangaIndexDao().upsert(manga.toEntity())
|
||||
suspend fun put(manga: LocalManga) = mutex.withLock {
|
||||
db.withTransaction {
|
||||
mangaDataRepository.storeManga(manga.manga)
|
||||
db.getLocalMangaIndexDao().upsert(manga.toEntity())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun delete(mangaId: Long) {
|
||||
|
||||
@@ -108,7 +108,7 @@ class LocalListViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
override fun createEmptyState(canResetFilter: Boolean): EmptyState = if (canResetFilter) {
|
||||
super.createEmptyState(canResetFilter)
|
||||
super.createEmptyState(true)
|
||||
} else {
|
||||
EmptyState(
|
||||
icon = R.drawable.ic_empty_local,
|
||||
|
||||
@@ -66,21 +66,32 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner {
|
||||
}
|
||||
|
||||
override fun onEmptyActionClick() {
|
||||
viewModel.resetFilter()
|
||||
if (filterCoordinator.isFilterApplied) {
|
||||
filterCoordinator.reset()
|
||||
} else {
|
||||
openInBrowser()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSecondaryErrorActionClick(error: Throwable) {
|
||||
viewModel.browserUrl?.also { url ->
|
||||
openInBrowser()
|
||||
}
|
||||
|
||||
private fun openInBrowser() {
|
||||
val browserUrl = viewModel.browserUrl
|
||||
if (browserUrl.isNullOrEmpty()) {
|
||||
Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
|
||||
.show()
|
||||
} else {
|
||||
startActivity(
|
||||
BrowserActivity.newIntent(
|
||||
requireContext(),
|
||||
url,
|
||||
browserUrl,
|
||||
viewModel.source,
|
||||
viewModel.source.getTitle(requireContext()),
|
||||
),
|
||||
)
|
||||
} ?: Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class RemoteListMenuProvider : MenuProvider {
|
||||
@@ -106,7 +117,7 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner {
|
||||
}
|
||||
|
||||
R.id.action_filter_reset -> {
|
||||
viewModel.resetFilter()
|
||||
filterCoordinator.reset()
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,6 @@ import org.koitharu.kotatsu.list.ui.model.toErrorFooter
|
||||
import org.koitharu.kotatsu.list.ui.model.toErrorState
|
||||
import org.koitharu.kotatsu.parsers.exception.NotFoundException
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaListFilter
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val FILTER_MIN_INTERVAL = 250L
|
||||
@@ -51,7 +49,7 @@ private const val FILTER_MIN_INTERVAL = 250L
|
||||
open class RemoteListViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
mangaRepositoryFactory: MangaRepository.Factory,
|
||||
override val filterCoordinator: FilterCoordinator,
|
||||
final override val filterCoordinator: FilterCoordinator,
|
||||
settings: AppSettings,
|
||||
mangaListMapper: MangaListMapper,
|
||||
downloadScheduler: DownloadWorker.Scheduler,
|
||||
@@ -132,12 +130,6 @@ open class RemoteListViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun resetFilter() = filterCoordinator.reset()
|
||||
|
||||
override fun onUpdateFilter(tags: Set<MangaTag>) {
|
||||
filterCoordinator.set(MangaListFilter(tags = tags))
|
||||
}
|
||||
|
||||
protected fun loadList(filterState: FilterCoordinator.Snapshot, append: Boolean): Job {
|
||||
loadingJob?.let {
|
||||
if (it.isActive) return it
|
||||
@@ -178,7 +170,7 @@ open class RemoteListViewModel @Inject constructor(
|
||||
icon = R.drawable.ic_empty_common,
|
||||
textPrimary = R.string.nothing_found,
|
||||
textSecondary = 0,
|
||||
actionStringRes = if (canResetFilter) R.string.reset_filter else 0,
|
||||
actionStringRes = if (canResetFilter) R.string.reset_filter else R.string.open_in_browser,
|
||||
)
|
||||
|
||||
protected open suspend fun onBuildList(list: MutableList<ListModel>) = Unit
|
||||
|
||||
@@ -142,8 +142,6 @@ class MultiSearchActivity :
|
||||
|
||||
override fun onFilterOptionClick(option: ListFilterOption) = Unit
|
||||
|
||||
override fun onUpdateFilter(tags: Set<MangaTag>) = Unit
|
||||
|
||||
override fun onFilterClick(view: View?) = Unit
|
||||
|
||||
override fun onEmptyActionClick() = Unit
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.koitharu.kotatsu.suggestions.data
|
||||
|
||||
import android.database.DatabaseUtils.sqlEscapeString
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
@@ -48,6 +49,9 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback {
|
||||
@Query("SELECT tags.* FROM suggestions LEFT JOIN tags ON (tag_id IN (SELECT tag_id FROM manga_tags WHERE manga_tags.manga_id = suggestions.manga_id)) GROUP BY tag_id ORDER BY COUNT(tags.tag_id) DESC LIMIT :limit")
|
||||
abstract suspend fun getTopTags(limit: Int): List<TagEntity>
|
||||
|
||||
@Query("SELECT manga.source AS count FROM suggestions LEFT JOIN manga ON manga.manga_id = suggestions.manga_id GROUP BY manga.source ORDER BY COUNT(manga.source) DESC LIMIT :limit")
|
||||
abstract suspend fun getTopSources(limit: Int): List<String>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract suspend fun insert(entity: SuggestionEntity): Long
|
||||
|
||||
@@ -71,6 +75,7 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback {
|
||||
override fun getCondition(option: ListFilterOption): String? = when (option) {
|
||||
ListFilterOption.Macro.NSFW -> "(SELECT nsfw FROM manga WHERE manga.manga_id = suggestions.manga_id) = 1"
|
||||
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE manga_tags.manga_id = suggestions.manga_id AND tag_id = ${option.tagId})"
|
||||
is ListFilterOption.Source -> "(SELECT source FROM manga WHERE manga.manga_id = suggestions.manga_id) = ${sqlEscapeString(option.mangaSource.name)}"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ import org.koitharu.kotatsu.core.db.entity.toEntity
|
||||
import org.koitharu.kotatsu.core.db.entity.toManga
|
||||
import org.koitharu.kotatsu.core.db.entity.toMangaTags
|
||||
import org.koitharu.kotatsu.core.db.entity.toMangaTagsList
|
||||
import org.koitharu.kotatsu.core.model.toMangaSources
|
||||
import org.koitharu.kotatsu.core.util.ext.mapItems
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.parsers.model.Manga
|
||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||
import org.koitharu.kotatsu.parsers.model.MangaTag
|
||||
import org.koitharu.kotatsu.suggestions.data.SuggestionEntity
|
||||
import javax.inject.Inject
|
||||
@@ -56,6 +58,11 @@ class SuggestionRepository @Inject constructor(
|
||||
.toMangaTagsList()
|
||||
}
|
||||
|
||||
suspend fun getTopSources(limit: Int): List<MangaSource> {
|
||||
return db.getSuggestionDao().getTopSources(limit)
|
||||
.toMangaSources()
|
||||
}
|
||||
|
||||
suspend fun replace(suggestions: Iterable<MangaSuggestion>) {
|
||||
db.withTransaction {
|
||||
db.getSuggestionDao().deleteAll()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.koitharu.kotatsu.suggestions.domain
|
||||
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.domain.MangaListQuickFilter
|
||||
@@ -19,5 +18,8 @@ class SuggestionsListQuickFilter @Inject constructor(
|
||||
add(ListFilterOption.Macro.NSFW)
|
||||
add(ListFilterOption.SFW)
|
||||
}
|
||||
suggestionRepository.getTopSources(3).mapTo(this) {
|
||||
ListFilterOption.Source(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import org.koitharu.kotatsu.core.ui.widgets.TipView
|
||||
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
|
||||
import org.koitharu.kotatsu.core.util.ext.observe
|
||||
import org.koitharu.kotatsu.core.util.ext.observeEvent
|
||||
import org.koitharu.kotatsu.databinding.FragmentFeedBinding
|
||||
import org.koitharu.kotatsu.databinding.FragmentListBinding
|
||||
import org.koitharu.kotatsu.details.ui.DetailsActivity
|
||||
import org.koitharu.kotatsu.list.domain.ListFilterOption
|
||||
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
|
||||
@@ -39,7 +39,7 @@ import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class FeedFragment :
|
||||
BaseFragment<FragmentFeedBinding>(),
|
||||
BaseFragment<FragmentListBinding>(),
|
||||
PaginationScrollListener.Callback,
|
||||
MangaListListener, SwipeRefreshLayout.OnRefreshListener {
|
||||
|
||||
@@ -53,9 +53,9 @@ class FeedFragment :
|
||||
override fun onCreateViewBinding(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
) = FragmentFeedBinding.inflate(inflater, container, false)
|
||||
) = FragmentListBinding.inflate(inflater, container, false)
|
||||
|
||||
override fun onViewBindingCreated(binding: FragmentFeedBinding, savedInstanceState: Bundle?) {
|
||||
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
|
||||
super.onViewBindingCreated(binding, savedInstanceState)
|
||||
val sizeResolver = StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width))
|
||||
feedAdapter = FeedAdapter(coil, viewLifecycleOwner, this, sizeResolver) { item, v ->
|
||||
@@ -99,8 +99,6 @@ class FeedFragment :
|
||||
|
||||
override fun onRetryClick(error: Throwable) = Unit
|
||||
|
||||
override fun onUpdateFilter(tags: Set<MangaTag>) = Unit
|
||||
|
||||
override fun onFilterClick(view: View?) = Unit
|
||||
|
||||
override fun onEmptyActionClick() = Unit
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
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">
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipeRefreshLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<org.koitharu.kotatsu.core.ui.list.fastscroll.FastScrollRecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="vertical"
|
||||
android:paddingVertical="@dimen/list_spacing_normal"
|
||||
app:bubbleSize="small"
|
||||
app:layoutManager="org.koitharu.kotatsu.core.ui.list.FitHeightLinearLayoutManager"
|
||||
tools:listitem="@layout/item_feed" />
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
</FrameLayout>
|
||||
@@ -58,25 +58,33 @@
|
||||
android:background="?attr/colorSecondaryContainer"
|
||||
android:backgroundTintMode="src_atop"
|
||||
android:scaleType="centerCrop"
|
||||
app:layout_constraintBottom_toBottomOf="@id/guideline"
|
||||
app:layout_constraintBottom_toBottomOf="@id/imageView_cover2"
|
||||
app:layout_constraintDimensionRatio="W,13:18"
|
||||
app:layout_constraintStart_toStartOf="@id/guideline_start"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/imageView_cover2"
|
||||
app:layout_goneMarginTop="0dp"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
|
||||
tools:src="@tools:sample/backgrounds/scenic" />
|
||||
|
||||
<androidx.constraintlayout.widget.Barrier
|
||||
android:id="@+id/barrier_covers"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:barrierDirection="end"
|
||||
app:constraint_referenced_ids="imageView_cover1,imageView_cover2,imageView_cover3" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginStart="@dimen/margin_normal"
|
||||
android:layout_marginEnd="?listPreferredItemPaddingEnd"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center_vertical|start"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
app:layout_constrainedHeight="true"
|
||||
app:layout_constraintBottom_toBottomOf="@id/guideline"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/imageView_cover3"
|
||||
app:layout_constraintStart_toEndOf="@id/barrier_covers"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:text="@tools:sample/lorem[22]" />
|
||||
|
||||
|
||||
@@ -325,7 +325,7 @@
|
||||
<string name="manga_error_description_pattern">Error details:<br><tt>%1$s</tt><br><br>1. Try to <a href="%2$s">open manga in a web browser</a> to ensure it is available on its source<br>2. Make sure you are using the <a href="kotatsu://about">latest version of Kotatsu</a><br>3. If it is available, send an error report to the developers.</string>
|
||||
<string name="history_shortcuts">Show recent manga shortcuts</string>
|
||||
<string name="history_shortcuts_summary">Make recent manga available by long pressing on application icon</string>
|
||||
<string name="reader_control_ltr_summary">Tapping on the right edge, or pressing the right key, always switches to the next page.</string>
|
||||
<string name="reader_control_ltr_summary">Do not adjust the page switching direction to the reader mode, e. g. pressing the right key always switches to the next page. This option affects only hardware input devices</string>
|
||||
<string name="reader_control_ltr">Ergonomic reader control</string>
|
||||
<string name="color_correction">Color correction</string>
|
||||
<string name="brightness">Brightness</string>
|
||||
|
||||
Reference in New Issue
Block a user