Quick filter in history draft implementation

This commit is contained in:
Koitharu
2024-08-03 16:22:46 +03:00
parent 6e92d46a63
commit d00822a6c3
19 changed files with 218 additions and 22 deletions

View File

@@ -5,6 +5,7 @@ import android.util.AttributeSet
import android.view.View.OnClickListener
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.core.view.children
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable
@@ -92,7 +93,11 @@ class ChipsView @JvmOverloads constructor(
}
private fun bindChip(chip: Chip, model: ChipModel) {
chip.text = model.title
if (model.titleResId == 0) {
chip.text = model.title
} else {
chip.setText(model.titleResId)
}
chip.isClickable = onChipClickListener != null || model.isCheckable
chip.isCheckable = model.isCheckable
if (model.icon == 0) {
@@ -139,7 +144,8 @@ class ChipsView @JvmOverloads constructor(
}
data class ChipModel(
val title: CharSequence,
val title: CharSequence? = null,
@StringRes val titleResId: Int = 0,
@DrawableRes val icon: Int = 0,
val isCheckable: Boolean = false,
@ColorRes val tint: Int = 0,

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.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.ListSortOrder
@Dao
@@ -27,7 +28,11 @@ abstract class HistoryDao {
@Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC LIMIT :limit")
abstract fun observeAll(limit: Int): Flow<List<HistoryWithManga>>
fun observeAll(order: ListSortOrder, limit: Int): Flow<List<HistoryWithManga>> {
fun observeAll(
order: ListSortOrder,
filterOptions: Set<ListFilterOption>,
limit: Int
): Flow<List<HistoryWithManga>> {
val orderBy = when (order) {
ListSortOrder.LAST_READ -> "history.updated_at DESC"
ListSortOrder.LONG_AGO_READ -> "history.updated_at ASC"
@@ -44,8 +49,13 @@ abstract class HistoryDao {
val query = buildString {
append(
"SELECT * FROM history LEFT JOIN manga ON history.manga_id = manga.manga_id " +
"WHERE history.deleted_at = 0 GROUP BY history.manga_id ORDER BY ",
"WHERE history.deleted_at = 0",
)
for (option in filterOptions) {
append(" AND ")
append(option.getCondition())
}
append(" GROUP BY history.manga_id ORDER BY ")
append(orderBy)
if (limit > 0) {
append(" LIMIT ")
@@ -147,4 +157,11 @@ abstract class HistoryDao {
@Transaction
@RawQuery(observedEntities = [HistoryEntity::class])
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")
}
}

View File

@@ -23,6 +23,7 @@ import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.mapItems
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.ReadingProgress
import org.koitharu.kotatsu.parsers.model.Manga
@@ -76,8 +77,12 @@ class HistoryRepository @Inject constructor(
}
}
fun observeAllWithHistory(order: ListSortOrder, limit: Int): Flow<List<MangaWithHistory>> {
return db.getHistoryDao().observeAll(order, limit).mapItems {
fun observeAllWithHistory(
order: ListSortOrder,
filterOptions: Set<ListFilterOption>,
limit: Int
): Flow<List<MangaWithHistory>> {
return db.getHistoryDao().observeAll(order, filterOptions, limit).mapItems {
MangaWithHistory(
it.manga.toManga(it.tags.toMangaTags()),
it.history.toMangaHistory(),

View File

@@ -16,6 +16,7 @@ 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
@@ -34,6 +35,10 @@ class HistoryListFragment : MangaListFragment() {
override fun onScrolledToEnd() = viewModel.requestMoreItems()
override fun onFilterOptionClick(option: ListFilterOption) {
viewModel.onFilterOptionClick(option)
}
override fun onEmptyActionClick() {
startActivity(NetworkManageIntent())
}

View File

@@ -21,13 +21,16 @@ 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.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.ui.MangaListViewModel
@@ -36,11 +39,13 @@ 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
@@ -63,6 +68,8 @@ 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,
@@ -86,25 +93,25 @@ class HistoryListViewModel @Inject constructor(
)
override val content = combine(
filterOptions,
observeHistory(),
isGroupingEnabled,
observeListModeWithTriggers(),
networkState,
settings.observeAsFlow(AppSettings.KEY_INCOGNITO_MODE) { isIncognitoModeEnabled },
) { list, grouped, mode, online, incognito ->
) { filters, list, grouped, mode, online, incognito ->
when {
list.isEmpty() -> listOf(
EmptyState(
icon = R.drawable.ic_empty_history,
textPrimary = R.string.text_history_holder_primary,
textSecondary = R.string.text_history_holder_secondary,
actionStringRes = 0,
),
)
list.isEmpty() -> {
if (filters.isEmpty()) {
listOf(getEmptyState(hasFilters = false))
} else {
listOf(filterItem(filters), getEmptyState(hasFilters = true))
}
}
else -> {
isReady.set(true)
mapList(list, grouped, mode, online, incognito)
mapList(filters, list, grouped, mode, online, incognito)
}
}
}.onStart {
@@ -154,17 +161,29 @@ class HistoryListViewModel @Inject constructor(
}
}
private fun observeHistory() = combine(sortOrder, limit, ::Pair)
.flatMapLatest { repository.observeAllWithHistory(it.first, it.second) }
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 suspend fun mapList(
filters: Set<ListFilterOption>,
list: List<MangaWithHistory>,
grouped: Boolean,
mode: ListMode,
isOnline: Boolean,
isIncognito: Boolean,
): List<ListModel> {
val result = ArrayList<ListModel>(if (grouped) (list.size * 1.4).toInt() else list.size + 2)
val result = ArrayList<ListModel>((if (grouped) (list.size * 1.4).toInt() else list.size) + 3)
result += filterItem(filters)
if (isIncognito) {
result += TipModel(
key = AppSettings.KEY_INCOGNITO_MODE,
@@ -185,12 +204,14 @@ class HistoryListViewModel @Inject constructor(
actionStringRes = R.string.manage,
)
}
var isEmpty = true
for ((m, history) in list) {
val manga = if (!isOnline && !m.isLocal) {
val manga = if ((!isOnline && !m.isLocal) || ListFilterOption.DOWNLOADED in filters) {
localMangaRepository.findSavedManga(m)?.manga ?: continue
} else {
m
}
isEmpty = false
if (grouped) {
val header = history.header(order)
if (header != prevHeader) {
@@ -202,6 +223,9 @@ class HistoryListViewModel @Inject constructor(
}
result += mangaListMapper.toListModel(manga, mode)
}
if (filters.isNotEmpty() && isEmpty) {
result += getEmptyState(hasFilters = true)
}
return result
}
@@ -229,4 +253,32 @@ class HistoryListViewModel @Inject constructor(
ListSortOrder.UPDATED,
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,
textPrimary = R.string.nothing_found,
textSecondary = R.string.text_history_holder_secondary_filtered,
actionStringRes = 0,
)
} else {
EmptyState(
icon = R.drawable.ic_empty_history,
textPrimary = R.string.text_history_holder_primary,
textSecondary = R.string.text_history_holder_secondary,
actionStringRes = 0,
)
}
}

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.list.domain
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import java.util.EnumSet
enum class ListFilterOption(
@StringRes val titleResId: Int,
@DrawableRes val iconResId: Int,
) {
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),
;
companion object {
val HISTORY: Set<ListFilterOption> = EnumSet.of(
DOWNLOADED,
NEW_CHAPTERS,
FAVORITE,
COMPLETED,
)
}
}

View File

@@ -44,6 +44,7 @@ import org.koitharu.kotatsu.databinding.FragmentListBinding
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.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.MangaListAdapter
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
@@ -226,6 +227,8 @@ abstract class MangaListFragment :
}
}
override fun onFilterOptionClick(option: ListFilterOption) = Unit
override fun onFilterClick(view: View?) = Unit
override fun onEmptyActionClick() = Unit

View File

@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.list.ui.adapter
enum class ListItemType {
FILTER_HEADER,
FILTER_SORT,
FILTER_TAG,
FILTER_TAG_MULTI,

View File

@@ -24,6 +24,7 @@ open class MangaListAdapter(
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener))
addDelegate(ListItemType.HINT_EMPTY, emptyHintAD(coil, lifecycleOwner, listener))
addDelegate(ListItemType.HEADER, listHeaderAD(listener))
addDelegate(ListItemType.FILTER_HEADER, quickFilterAD(listener))
addDelegate(ListItemType.TIP, tipAD(listener))
}
}

View File

@@ -52,7 +52,7 @@ fun mangaListDetailedItemAD(
source(item.source)
enqueueWith(coil)
}
binding.textViewTags.text = item.tags.joinToString(separator = ", ") { it.title }
binding.textViewTags.text = item.tags.joinToString(separator = ", ") { it.title ?: "" }
badge = itemView.bindBadge(badge, item.counter)
}
}

View File

@@ -5,7 +5,7 @@ import org.koitharu.kotatsu.core.ui.widgets.TipView
import org.koitharu.kotatsu.parsers.model.MangaTag
interface MangaListListener : MangaDetailsClickListener, ListStateHolderListener, ListHeaderClickListener,
TipView.OnButtonClickListener {
TipView.OnButtonClickListener, QuickFilterClickListener {
fun onUpdateFilter(tags: Set<MangaTag>)

View File

@@ -0,0 +1,25 @@
package org.koitharu.kotatsu.list.ui.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.databinding.ItemQuickFilterBinding
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.QuickFilter
fun quickFilterAD(
listener: QuickFilterClickListener,
) = adapterDelegateViewBinding<QuickFilter, ListModel, ItemQuickFilterBinding>(
{ layoutInflater, parent -> ItemQuickFilterBinding.inflate(layoutInflater, parent, false) }
) {
binding.chipsTags.onChipClickListener = ChipsView.OnChipClickListener { chip, data ->
if (data is ListFilterOption) {
listener.onFilterOptionClick(data)
}
}
bind {
binding.chipsTags.setChips(item.items)
}
}

View File

@@ -0,0 +1,8 @@
package org.koitharu.kotatsu.list.ui.adapter
import org.koitharu.kotatsu.list.domain.ListFilterOption
interface QuickFilterClickListener {
fun onFilterOptionClick(option: ListFilterOption)
}

View File

@@ -32,6 +32,7 @@ class TypedListSpacingDecoration(
ListItemType.FILTER_TAG_MULTI,
ListItemType.FILTER_STATE,
ListItemType.FILTER_LANGUAGE,
ListItemType.FILTER_HEADER,
-> outRect.set(0)
ListItemType.HEADER,

View File

@@ -0,0 +1,13 @@
package org.koitharu.kotatsu.list.ui.model
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
data class QuickFilter(
val items: List<ChipsView.ChipModel>,
) : ListModel {
override fun areItemsTheSame(other: ListModel): Boolean = other is QuickFilter
override fun getChangePayload(previousState: ListModel) = ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED
}

View File

@@ -27,6 +27,7 @@ import org.koitharu.kotatsu.databinding.ActivitySearchMultiBinding
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.ui.MangaSelectionDecoration
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
@@ -133,6 +134,8 @@ class MultiSearchActivity :
viewModel.retry()
}
override fun onFilterOptionClick(option: ListFilterOption) = Unit
override fun onUpdateFilter(tags: Set<MangaTag>) = Unit
override fun onFilterClick(view: View?) = Unit

View File

@@ -24,6 +24,7 @@ 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.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.ui.adapter.MangaListListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader
@@ -94,6 +95,8 @@ class FeedFragment :
viewModel.update()
}
override fun onFilterOptionClick(option: ListFilterOption) = Unit
override fun onRetryClick(error: Throwable) = Unit
override fun onUpdateFilter(tags: Set<MangaTag>) = Unit

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<HorizontalScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/scrollView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipToPadding="false"
android:paddingHorizontal="@dimen/list_spacing"
android:scrollbars="none">
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_tags"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:paddingVertical="2dp"
app:chipStyle="@style/Widget.Kotatsu.Chip.Filter"
app:selectionRequired="false"
app:singleLine="true"
app:singleSelection="false" />
</HorizontalScrollView>

View File

@@ -96,6 +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_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>