Quick filter for favorites

This commit is contained in:
Koitharu
2024-08-12 15:28:14 +03:00
parent 8b71f99666
commit 00396f2e1b
15 changed files with 194 additions and 74 deletions

View File

@@ -17,6 +17,9 @@ class NetworkState(
private val callback = NetworkCallbackImpl()
override val value: Boolean
get() = connectivityManager.isOnline(settings)
@Synchronized
override fun onActive() {
invalidate()

View File

@@ -13,7 +13,7 @@ abstract class MediatorStateFlow<T>(initialValue: T) : StateFlow<T> {
final override val replayCache: List<T>
get() = delegate.replayCache
final override val value: T
override val value: T
get() = delegate.value
final override suspend fun collect(collector: FlowCollector<T>): Nothing {

View File

@@ -11,7 +11,9 @@ import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.intellij.lang.annotations.Language
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.ListSortOrder
@Dao
@@ -27,21 +29,11 @@ abstract class FavouritesDao {
@Query("SELECT * FROM favourites WHERE deleted_at = 0 GROUP BY manga_id ORDER BY created_at DESC LIMIT :limit")
abstract suspend fun findLast(limit: Int): List<FavouriteManga>
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<FavouriteManga>> {
val orderBy = getOrderBy(order)
val query = buildString {
append(
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
"WHERE favourites.deleted_at = 0 GROUP BY favourites.manga_id ORDER BY ",
)
append(orderBy)
if (limit > 0) {
append(" LIMIT ")
append(limit)
}
}
return observeAllImpl(SimpleSQLiteQuery(query))
}
fun observeAll(
order: ListSortOrder,
filterOptions: Set<ListFilterOption>,
limit: Int
): Flow<List<FavouriteManga>> = observeAll(0L, order, filterOptions, limit)
@Transaction
@Query("SELECT * FROM favourites WHERE deleted_at = 0 ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
@@ -57,13 +49,37 @@ abstract class FavouritesDao {
)
abstract suspend fun findAll(categoryId: Long): List<FavouriteManga>
fun observeAll(categoryId: Long, order: ListSortOrder, limit: Int): Flow<List<FavouriteManga>> {
fun observeAll(
categoryId: Long,
order: ListSortOrder,
filterOptions: Set<ListFilterOption>,
limit: Int
): Flow<List<FavouriteManga>> {
val orderBy = getOrderBy(order)
val query = buildString {
append(
"SELECT * FROM favourites LEFT JOIN manga ON favourites.manga_id = manga.manga_id " +
"WHERE category_id = ? AND deleted_at = 0 GROUP BY favourites.manga_id ORDER BY ",
"WHERE deleted_at = 0",
)
if (categoryId != 0L) {
append(" AND category_id = ")
append(categoryId)
}
val groupedOptions = filterOptions.groupBy { it.groupKey }
for ((_, group) in groupedOptions) {
if (group.isEmpty()) {
continue
}
append(" AND ")
if (group.size > 1) {
group.joinTo(this, separator = " OR ", prefix = "(", postfix = ")") {
it.getCondition()
}
} else {
append(group.single().getCondition())
}
}
append(" GROUP BY favourites.manga_id ORDER BY ")
append(orderBy)
if (limit > 0) {
append(" LIMIT ")
@@ -71,7 +87,7 @@ abstract class FavouritesDao {
}
}
return observeAllImpl(SimpleSQLiteQuery(query, arrayOf<Any>(categoryId)))
return observeAllImpl(SimpleSQLiteQuery(query))
}
suspend fun findCovers(categoryId: Long, order: ListSortOrder): List<Cover> {
@@ -191,4 +207,11 @@ abstract class FavouritesDao {
else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
}
private fun ListFilterOption.getCondition(): String = when (this) {
ListFilterOption.Macro.COMPLETED -> "EXISTS(SELECT * FROM history WHERE history.manga_id = favourites.manga_id AND history.percent >= 0.9999)"
ListFilterOption.Macro.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = favourites.manga_id) > 0"
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE favourites.manga_id = manga_tags.manga_id AND tag_id = ${tag.toEntity().id})"
else -> throw IllegalArgumentException("Unsupported option $this")
}
}

View File

@@ -0,0 +1,26 @@
package org.koitharu.kotatsu.favourites.domain
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(
private val settings: AppSettings,
private val repository: FavouritesRepository,
networkState: NetworkState,
) : MangaListQuickFilter() {
init {
setFilterOption(ListFilterOption.Downloaded, !networkState.value)
}
override suspend fun getAvailableFilterOptions(): List<ListFilterOption> = buildList {
add(ListFilterOption.Downloaded)
if (settings.isTrackerEnabled) {
add(ListFilterOption.Macro.NEW_CHAPTERS)
}
add(ListFilterOption.Macro.COMPLETED)
}
}

View File

@@ -19,6 +19,7 @@ import org.koitharu.kotatsu.favourites.data.toFavouriteCategory
import org.koitharu.kotatsu.favourites.data.toManga
import org.koitharu.kotatsu.favourites.data.toMangaList
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 javax.inject.Inject
@@ -38,8 +39,8 @@ class FavouritesRepository @Inject constructor(
return entities.toMangaList()
}
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<Manga>> {
return db.getFavouritesDao().observeAll(order, limit)
fun observeAll(order: ListSortOrder, filterOptions: Set<ListFilterOption>, limit: Int): Flow<List<Manga>> {
return db.getFavouritesDao().observeAll(order, filterOptions, limit)
.mapItems { it.toManga() }
}
@@ -48,14 +49,19 @@ class FavouritesRepository @Inject constructor(
return entities.toMangaList()
}
fun observeAll(categoryId: Long, order: ListSortOrder, limit: Int): Flow<List<Manga>> {
return db.getFavouritesDao().observeAll(categoryId, order, limit)
fun observeAll(
categoryId: Long,
order: ListSortOrder,
filterOptions: Set<ListFilterOption>,
limit: Int
): Flow<List<Manga>> {
return db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit)
.mapItems { it.toManga() }
}
fun observeAll(categoryId: Long, limit: Int): Flow<List<Manga>> {
fun observeAll(categoryId: Long, filterOptions: Set<ListFilterOption>, limit: Int): Flow<List<Manga>> {
return observeOrder(categoryId)
.flatMapLatest { order -> observeAll(categoryId, order, limit) }
.flatMapLatest { order -> observeAll(categoryId, order, filterOptions, limit) }
}
fun observeMangaCount(): Flow<Int> {

View File

@@ -35,6 +35,8 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis
override fun onScrolledToEnd() = viewModel.requestMoreItems()
override fun onEmptyActionClick() = viewModel.clearFilter()
override fun onFilterClick(view: View?) {
val menu = PopupMenu(view?.context ?: return, view)
menu.setOnMenuItemClickListener(this)

View File

@@ -4,6 +4,9 @@ import androidx.lifecycle.SavedStateHandle
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
@@ -15,21 +18,28 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.favourites.domain.FavoritesListQuickFilter
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase
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.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.local.data.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
@@ -42,9 +52,11 @@ class FavouritesListViewModel @Inject constructor(
private val repository: FavouritesRepository,
private val mangaListMapper: MangaListMapper,
private val markAsReadUseCase: MarkAsReadUseCase,
private val quickFilter: FavoritesListQuickFilter,
private val localMangaRepository: LocalMangaRepository,
settings: AppSettings,
downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler) {
) : MangaListViewModel(settings, downloadScheduler), QuickFilterListener by quickFilter {
val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID
private val refreshTrigger = MutableStateFlow(Any())
@@ -66,26 +78,19 @@ class FavouritesListViewModel @Inject constructor(
override val content = combine(
observeFavorites(),
quickFilter.appliedOptions,
observeListModeWithTriggers(),
refreshTrigger,
) { list, mode, _ ->
) { list, filters, mode, _ ->
when {
list.isEmpty() -> listOf(
EmptyState(
icon = R.drawable.ic_empty_favourites,
textPrimary = R.string.text_empty_holder_primary,
textSecondary = if (categoryId == NO_ID) {
R.string.you_have_not_favourites_yet
} else {
R.string.favourites_category_empty
},
actionStringRes = 0,
),
)
list.isEmpty() -> if (filters.isEmpty()) {
listOf(getEmptyState(hasFilters = false))
} else {
listOf(quickFilter.filterItem(filters), getEmptyState(hasFilters = true))
}
else -> {
isReady.set(true)
mangaListMapper.toListModelList(list, mode)
list.mapList(mode, filters).also { isReady.set(true) }
}
}
}.catch {
@@ -134,12 +139,55 @@ class FavouritesListViewModel @Inject constructor(
}
}
private fun observeFavorites() = if (categoryId == NO_ID) {
combine(sortOrder.filterNotNull(), limit, ::Pair)
.flatMapLatest { repository.observeAll(it.first, it.second) }
} else {
limit.flatMapLatest {
repository.observeAll(categoryId, it)
private suspend fun List<Manga>.mapList(mode: ListMode, filters: Set<ListFilterOption>): List<ListModel> {
val list = if (ListFilterOption.Downloaded in filters) {
mapToLocal()
} else {
this
}
val result = ArrayList<ListModel>(list.size + 1)
result += quickFilter.filterItem(filters)
mangaListMapper.toListModelList(result, list, mode)
return result
}
private fun observeFavorites() = if (categoryId == NO_ID) {
combine(sortOrder.filterNotNull(), quickFilter.appliedOptions, limit, ::Triple)
.flatMapLatest { repository.observeAll(it.first, it.second - ListFilterOption.Downloaded, it.third) }
} else {
combine(quickFilter.appliedOptions, limit, ::Pair)
.flatMapLatest { repository.observeAll(categoryId, it.first - ListFilterOption.Downloaded, it.second) }
}
private fun getEmptyState(hasFilters: Boolean) = if (hasFilters) {
EmptyState(
icon = R.drawable.ic_empty_favourites,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_empty_holder_secondary_filtered,
actionStringRes = R.string.reset_filter,
)
} else {
EmptyState(
icon = R.drawable.ic_empty_favourites,
textPrimary = R.string.text_empty_holder_primary,
textSecondary = if (categoryId == NO_ID) {
R.string.you_have_not_favourites_yet
} else {
R.string.favourites_category_empty
},
actionStringRes = 0,
)
}
private suspend fun List<Manga>.mapToLocal(): List<Manga> = coroutineScope {
map {
async {
if (it.isLocal) {
it
} else {
localMangaRepository.findSavedManga(it)?.manga
}
}
}.awaitAll().filterNotNull()
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.history.domain
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.domain.ListFilterOption
@@ -9,8 +10,13 @@ import javax.inject.Inject
class HistoryListQuickFilter @Inject constructor(
private val settings: AppSettings,
private val repository: HistoryRepository,
networkState: NetworkState,
) : MangaListQuickFilter() {
init {
setFilterOption(ListFilterOption.Downloaded, !networkState.value)
}
override suspend fun getAvailableFilterOptions(): List<ListFilterOption> = buildList {
add(ListFilterOption.Downloaded)
if (settings.isTrackerEnabled) {

View File

@@ -9,7 +9,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.NetworkManageIntent
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
@@ -34,9 +33,7 @@ class HistoryListFragment : MangaListFragment() {
override fun onScrolledToEnd() = viewModel.requestMoreItems()
override fun onEmptyActionClick() {
startActivity(NetworkManageIntent())
}
override fun onEmptyActionClick() = viewModel.clearFilter()
override fun onCreateActionMode(controller: ListSelectionController, mode: ActionMode, menu: Menu): Boolean {
mode.menuInflater.inflate(R.menu.mode_history, menu)

View File

@@ -18,7 +18,6 @@ import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.prefs.observeAsFlow
@@ -26,7 +25,6 @@ import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
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
@@ -38,7 +36,6 @@ 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
@@ -61,7 +58,6 @@ class HistoryListViewModel @Inject constructor(
private val localMangaRepository: LocalMangaRepository,
private val markAsReadUseCase: MarkAsReadUseCase,
private val quickFilter: HistoryListQuickFilter,
networkState: NetworkState,
downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler), QuickFilterListener by quickFilter {
@@ -98,9 +94,8 @@ class HistoryListViewModel @Inject constructor(
observeHistory(),
isGroupingEnabled,
observeListModeWithTriggers(),
networkState,
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled },
) { filters, list, grouped, mode, online, incognito ->
) { filters, list, grouped, mode, incognito ->
when {
list.isEmpty() -> {
if (filters.isEmpty()) {
@@ -112,7 +107,7 @@ class HistoryListViewModel @Inject constructor(
else -> {
isReady.set(true)
mapList(filters, list, grouped, mode, online, incognito)
mapList(list, grouped, mode, filters, incognito)
}
}
}.onStart {
@@ -166,19 +161,18 @@ class HistoryListViewModel @Inject constructor(
.flatMapLatest { repository.observeAllWithHistory(it.first, it.second - ListFilterOption.Downloaded, it.third) }
private suspend fun mapList(
filters: Set<ListFilterOption>,
historyList: List<MangaWithHistory>,
grouped: Boolean,
mode: ListMode,
isOnline: Boolean,
filters: Set<ListFilterOption>,
isIncognito: Boolean,
): List<ListModel> {
val list = if (!isOnline || ListFilterOption.Downloaded in filters) {
val list = if (ListFilterOption.Downloaded in filters) {
historyList.mapToLocal()
} else {
historyList
}
val result = ArrayList<ListModel>((if (grouped) (list.size * 1.4).toInt() else list.size) + 3)
val result = ArrayList<ListModel>((if (grouped) (list.size * 1.4).toInt() else list.size) + 2)
result += quickFilter.filterItem(filters)
if (isIncognito) {
result += TipModel(
@@ -192,14 +186,6 @@ class HistoryListViewModel @Inject constructor(
}
val order = sortOrder.value
var prevHeader: ListHeader? = null
if (!isOnline) {
result += EmptyHint(
icon = R.drawable.ic_empty_common,
textPrimary = R.string.network_unavailable,
textSecondary = R.string.network_unavailable_hint,
actionStringRes = R.string.manage,
)
}
var isEmpty = true
for ((manga, history) in list) {
isEmpty = false
@@ -263,8 +249,8 @@ class HistoryListViewModel @Inject constructor(
EmptyState(
icon = R.drawable.ic_empty_history,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_history_holder_secondary_filtered,
actionStringRes = 0,
textSecondary = R.string.text_empty_holder_secondary_filtered,
actionStringRes = R.string.reset_filter,
)
} else {
EmptyState(

View File

@@ -17,6 +17,16 @@ abstract class MangaListQuickFilter : QuickFilterListener {
val appliedOptions
get() = appliedFilter.asStateFlow()
override fun setFilterOption(option: ListFilterOption, isApplied: Boolean) {
appliedFilter.value = ArraySet(appliedFilter.value).also {
if (isApplied) {
it.add(option)
} else {
it.remove(option)
}
}
}
override fun toggleFilterOption(option: ListFilterOption) {
appliedFilter.value = ArraySet(appliedFilter.value).also {
if (option in it) {

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.list.domain
interface QuickFilterListener {
fun setFilterOption(option: ListFilterOption, isApplied: Boolean)
fun toggleFilterOption(option: ListFilterOption)
fun clearFilter()

View File

@@ -17,3 +17,12 @@ fun Throwable.toErrorState(canRetry: Boolean = true, @StringRes secondaryAction:
fun Throwable.toErrorFooter() = ErrorFooter(
exception = this,
)
operator fun ListModel.plus(list: List<ListModel>): List<ListModel> {
val result = ArrayList<ListModel>(list.size + 1)
result.add(this)
result.addAll(list)
return result
}
operator fun ListModel.plus(other: ListModel): List<ListModel> = listOf(this, other)

View File

@@ -5,6 +5,7 @@
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingHorizontal="@dimen/list_spacing"
android:scrollbars="none">
@@ -15,7 +16,8 @@
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingVertical="2dp"
android:paddingTop="2dp"
android:paddingBottom="6dp"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter"
app:selectionRequired="false"
app:singleLine="true"

View File

@@ -96,7 +96,7 @@
<string name="text_search_holder_secondary">Try to reformulate the query.</string>
<string name="text_history_holder_primary">What you read will be displayed here</string>
<string name="text_history_holder_secondary">Find what to read in the «Explore» section</string>
<string name="text_history_holder_secondary_filtered">There are no manga matching the filters you selected</string>
<string name="text_empty_holder_secondary_filtered">There are no manga matching the filters you selected</string>
<string name="text_local_holder_primary">Save something first</string>
<string name="text_local_holder_secondary">Save something from an online catalog or import it from a file.</string>
<string name="manga_shelf">Shelf</string>