Refactor quick filter implementation

This commit is contained in:
Koitharu
2024-08-04 10:22:49 +03:00
parent d00822a6c3
commit 8b71f99666
11 changed files with 230 additions and 72 deletions

1
.idea/gradle.xml generated
View File

@@ -4,6 +4,7 @@
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">

View File

@@ -10,6 +10,7 @@ import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.ListSortOrder
@@ -51,9 +52,19 @@ abstract class HistoryDao {
"SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " +
"WHERE history.deleted_at = 0",
)
for (option in filterOptions) {
val groupedOptions = filterOptions.groupBy { it.groupKey }
for ((_, group) in groupedOptions) {
if (group.isEmpty()) {
continue
}
append(" AND ")
append(option.getCondition())
if (group.size > 1) {
group.joinTo(this, separator = " OR ", prefix = "(", postfix = ")") {
it.getCondition()
}
} else {
append(group.single().getCondition())
}
}
append(" GROUP BY history.manga_id ORDER BY ")
append(orderBy)
@@ -159,9 +170,11 @@ abstract class HistoryDao {
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<HistoryWithManga>>
private fun ListFilterOption.getCondition(): String = when (this) {
ListFilterOption.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = history.manga_id) > 0"
ListFilterOption.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id)"
ListFilterOption.COMPLETED -> "percent >= 0.9999"
ListFilterOption.DOWNLOADED -> throw IllegalArgumentException("Unsupported option $this")
ListFilterOption.Downloaded -> throw IllegalArgumentException("Unsupported option $this")
is ListFilterOption.Favorite -> "EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id AND category_id = ${category.id})"
ListFilterOption.Macro.COMPLETED -> "percent >= 0.9999"
ListFilterOption.Macro.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = history.manga_id) > 0"
ListFilterOption.Macro.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id)"
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE history.manga_id = manga_tags.manga_id AND tag_id = ${tag.toEntity().id})"
}
}

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.history.domain
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.MangaListQuickFilter
import javax.inject.Inject
class HistoryListQuickFilter @Inject constructor(
private val settings: AppSettings,
private val repository: HistoryRepository,
) : MangaListQuickFilter() {
override suspend fun getAvailableFilterOptions(): List<ListFilterOption> = buildList {
add(ListFilterOption.Downloaded)
if (settings.isTrackerEnabled) {
add(ListFilterOption.Macro.NEW_CHAPTERS)
}
add(ListFilterOption.Macro.COMPLETED)
add(ListFilterOption.Macro.FAVORITE)
repository.getPopularTags(3).mapTo(this) {
ListFilterOption.Tag(it)
}
}
}

View File

@@ -16,7 +16,6 @@ import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
@@ -35,10 +34,6 @@ class HistoryListFragment : MangaListFragment() {
override fun onScrolledToEnd() = viewModel.requestMoreItems()
override fun onFilterOptionClick(option: ListFilterOption) {
viewModel.onFilterOptionClick(option)
}
override fun onEmptyActionClick() {
startActivity(NetworkManageIntent())
}

View File

@@ -3,6 +3,9 @@ package org.koitharu.kotatsu.history.ui
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@@ -21,31 +24,30 @@ import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.combine
import org.koitharu.kotatsu.core.util.ext.onFirst
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.history.domain.HistoryListQuickFilter
import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase
import org.koitharu.kotatsu.history.domain.model.MangaWithHistory
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.domain.QuickFilterListener
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyHint
import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.QuickFilter
import org.koitharu.kotatsu.list.ui.model.TipModel
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import java.time.Instant
import java.util.EnumSet
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
@@ -58,9 +60,10 @@ class HistoryListViewModel @Inject constructor(
private val mangaListMapper: MangaListMapper,
private val localMangaRepository: LocalMangaRepository,
private val markAsReadUseCase: MarkAsReadUseCase,
private val quickFilter: HistoryListQuickFilter,
networkState: NetworkState,
downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler) {
) : MangaListViewModel(settings, downloadScheduler), QuickFilterListener by quickFilter {
private val sortOrder: StateFlow<ListSortOrder> = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.IO,
@@ -68,8 +71,6 @@ class HistoryListViewModel @Inject constructor(
valueProducer = { historySortOrder },
)
private val filterOptions = MutableStateFlow<Set<ListFilterOption>>(EnumSet.noneOf(ListFilterOption::class.java))
override val listMode = settings.observeAsStateFlow(
scope = viewModelScope + Dispatchers.Default,
key = AppSettings.KEY_LIST_MODE_HISTORY,
@@ -93,7 +94,7 @@ class HistoryListViewModel @Inject constructor(
)
override val content = combine(
filterOptions,
quickFilter.appliedOptions,
observeHistory(),
isGroupingEnabled,
observeListModeWithTriggers(),
@@ -105,7 +106,7 @@ class HistoryListViewModel @Inject constructor(
if (filters.isEmpty()) {
listOf(getEmptyState(hasFilters = false))
} else {
listOf(filterItem(filters), getEmptyState(hasFilters = true))
listOf(quickFilter.filterItem(filters), getEmptyState(hasFilters = true))
}
}
@@ -118,8 +119,8 @@ class HistoryListViewModel @Inject constructor(
loadingCounter.increment()
}.onFirst {
loadingCounter.decrement()
}.catch {
emit(listOf(it.toErrorState(canRetry = false)))
}.catch { e ->
emit(listOf(e.toErrorState(canRetry = false)))
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
override fun onRefresh() = Unit
@@ -161,29 +162,24 @@ class HistoryListViewModel @Inject constructor(
}
}
fun onFilterOptionClick(option: ListFilterOption) {
filterOptions.value = EnumSet.copyOf(filterOptions.value).also {
if (option in it) {
it.remove(option)
} else {
it.add(option)
}
}
}
private fun observeHistory() = combine(sortOrder, filterOptions, limit, ::Triple)
.flatMapLatest { repository.observeAllWithHistory(it.first, it.second - ListFilterOption.DOWNLOADED, it.third) }
private fun observeHistory() = combine(sortOrder, quickFilter.appliedOptions, limit, ::Triple)
.flatMapLatest { repository.observeAllWithHistory(it.first, it.second - ListFilterOption.Downloaded, it.third) }
private suspend fun mapList(
filters: Set<ListFilterOption>,
list: List<MangaWithHistory>,
historyList: List<MangaWithHistory>,
grouped: Boolean,
mode: ListMode,
isOnline: Boolean,
isIncognito: Boolean,
): List<ListModel> {
val list = if (!isOnline || ListFilterOption.Downloaded in filters) {
historyList.mapToLocal()
} else {
historyList
}
val result = ArrayList<ListModel>((if (grouped) (list.size * 1.4).toInt() else list.size) + 3)
result += filterItem(filters)
result += quickFilter.filterItem(filters)
if (isIncognito) {
result += TipModel(
key = AppSettings.KEY_INCOGNITO_MODE,
@@ -205,12 +201,7 @@ class HistoryListViewModel @Inject constructor(
)
}
var isEmpty = true
for ((m, history) in list) {
val manga = if ((!isOnline && !m.isLocal) || ListFilterOption.DOWNLOADED in filters) {
localMangaRepository.findSavedManga(m)?.manga ?: continue
} else {
m
}
for ((manga, history) in list) {
isEmpty = false
if (grouped) {
val header = history.header(order)
@@ -229,6 +220,20 @@ class HistoryListViewModel @Inject constructor(
return result
}
private suspend fun List<MangaWithHistory>.mapToLocal() = coroutineScope {
map {
async {
if (it.manga.isLocal) {
it
} else {
localMangaRepository.findSavedManga(it.manga)?.let { localManga ->
MangaWithHistory(localManga.manga, it.history)
}
}
}
}.awaitAll().filterNotNull()
}
private fun MangaHistory.header(order: ListSortOrder): ListHeader? = when (order) {
ListSortOrder.LAST_READ,
ListSortOrder.LONG_AGO_READ -> ListHeader(calculateTimeAgo(updatedAt))
@@ -254,18 +259,6 @@ class HistoryListViewModel @Inject constructor(
ListSortOrder.RATING -> null
}
private fun filterItem(selected: Set<ListFilterOption>) = QuickFilter(
items = ListFilterOption.HISTORY.map { option ->
ChipsView.ChipModel(
titleResId = option.titleResId,
icon = option.iconResId,
isCheckable = true,
isChecked = option in selected,
data = option,
)
},
)
private fun getEmptyState(hasFilters: Boolean) = if (hasFilters) {
EmptyState(
icon = R.drawable.ic_empty_history,

View File

@@ -3,26 +3,84 @@ package org.koitharu.kotatsu.list.domain
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import java.util.EnumSet
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.parsers.model.MangaTag
enum class ListFilterOption(
@StringRes val titleResId: Int,
@DrawableRes val iconResId: Int,
) {
sealed interface ListFilterOption {
DOWNLOADED(R.string.on_device, R.drawable.ic_storage),
COMPLETED(R.string.status_completed, R.drawable.ic_state_finished),
NEW_CHAPTERS(R.string.new_chapters, R.drawable.ic_updated),
FAVORITE(R.string.favourites, R.drawable.ic_heart_outline),
;
@get:StringRes
val titleResId: Int
companion object {
@get:DrawableRes
val iconResId: Int
val HISTORY: Set<ListFilterOption> = EnumSet.of(
DOWNLOADED,
NEW_CHAPTERS,
FAVORITE,
COMPLETED,
)
val titleText: CharSequence?
val groupKey: String
data object Downloaded : ListFilterOption {
override val titleResId: Int
get() = R.string.on_device
override val iconResId: Int
get() = R.drawable.ic_storage
override val titleText: CharSequence?
get() = null
override val groupKey: String
get() = "_downloaded"
}
enum class Macro(
@StringRes override val titleResId: Int,
@DrawableRes override val iconResId: Int,
) : ListFilterOption {
COMPLETED(R.string.status_completed, R.drawable.ic_state_finished),
NEW_CHAPTERS(R.string.new_chapters, R.drawable.ic_updated),
FAVORITE(R.string.favourites, R.drawable.ic_heart_outline),
;
override val titleText: CharSequence?
get() = null
override val groupKey: String
get() = name
}
data class Tag(
val tag: MangaTag
) : ListFilterOption {
override val titleResId: Int
get() = 0
override val iconResId: Int
get() = R.drawable.ic_tag
override val titleText: String
get() = tag.title
override val groupKey: String
get() = "_tag"
}
data class Favorite(
val category: FavouriteCategory
) : ListFilterOption {
override val titleResId: Int
get() = 0
override val iconResId: Int
get() = R.drawable.ic_heart_outline
override val titleText: String
get() = category.title
override val groupKey: String
get() = "_favcat"
}
}

View File

@@ -0,0 +1,50 @@
package org.koitharu.kotatsu.list.domain
import androidx.collection.ArraySet
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.list.ui.model.QuickFilter
import org.koitharu.kotatsu.parsers.util.SuspendLazy
abstract class MangaListQuickFilter : QuickFilterListener {
private val appliedFilter = MutableStateFlow<Set<ListFilterOption>>(emptySet())
private val availableFilterOptions = SuspendLazy {
getAvailableFilterOptions()
}
val appliedOptions
get() = appliedFilter.asStateFlow()
override fun toggleFilterOption(option: ListFilterOption) {
appliedFilter.value = ArraySet(appliedFilter.value).also {
if (option in it) {
it.remove(option)
} else {
it.add(option)
}
}
}
override fun clearFilter() {
appliedFilter.value = emptySet()
}
suspend fun filterItem(
selectedOptions: Set<ListFilterOption>,
) = QuickFilter(
items = availableFilterOptions.tryGet().getOrNull()?.map { option ->
ChipsView.ChipModel(
title = option.titleText,
titleResId = option.titleResId,
icon = option.iconResId,
isCheckable = true,
isChecked = option in selectedOptions,
data = option,
)
}.orEmpty(),
)
protected abstract suspend fun getAvailableFilterOptions(): List<ListFilterOption>
}

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.list.domain
interface QuickFilterListener {
fun toggleFilterOption(option: ListFilterOption)
fun clearFilter()
}

View File

@@ -45,6 +45,7 @@ import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver
import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.QuickFilterListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
@@ -227,7 +228,9 @@ abstract class MangaListFragment :
}
}
override fun onFilterOptionClick(option: ListFilterOption) = Unit
override fun onFilterOptionClick(option: ListFilterOption) {
(viewModel as? QuickFilterListener)?.toggleFilterOption(option)
}
override fun onFilterClick(view: View?) = Unit

View File

@@ -65,7 +65,7 @@ abstract class MangaListViewModel(
listMode,
settings.observe().filter { key ->
key == AppSettings.KEY_PROGRESS_INDICATORS || key == AppSettings.KEY_TRACKER_ENABLED
}.onStart { emit("") }
}.onStart { emit("") },
) { mode, _ ->
mode
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M21.41 11.58L12.41 2.58A2 2 0 0 0 11 2H4A2 2 0 0 0 2 4V11A2 2 0 0 0 2.59 12.42L11.59 21.42A2 2 0 0 0 13 22A2 2 0 0 0 14.41 21.41L21.41 14.41A2 2 0 0 0 22 13A2 2 0 0 0 21.41 11.58M13 20L4 11V4H11L20 13M6.5 5A1.5 1.5 0 1 1 5 6.5A1.5 1.5 0 0 1 6.5 5Z" />
</vector>