Improve quick filters

This commit is contained in:
Koitharu
2024-09-23 14:36:29 +03:00
parent 169e31e9ba
commit b1eabdba79
31 changed files with 275 additions and 103 deletions

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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,

View File

@@ -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)
}
}
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -31,5 +31,8 @@ class HistoryListQuickFilter @Inject constructor(
repository.getPopularTags(3).mapTo(this) {
ListFilterOption.Tag(it)
}
repository.getPopularSources(3).mapTo(this) {
ListFilterOption.Source(it)
}
}
}

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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?)
}

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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()

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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]" />

View File

@@ -325,7 +325,7 @@
<string name="manga_error_description_pattern">Error details:&lt;br&gt;&lt;tt&gt;%1$s&lt;/tt&gt;&lt;br&gt;&lt;br&gt;1. Try to &lt;a href="%2$s"&gt;open manga in a web browser&lt;/a&gt; to ensure it is available on its source&lt;br&gt;2. Make sure you are using the &lt;a href="kotatsu://about"&gt;latest version of Kotatsu&lt;/a&gt;&lt;br&gt;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>