Quick filter in feed and updates lists

This commit is contained in:
Koitharu
2024-08-18 14:01:29 +03:00
parent b66d3ee8d4
commit 65abef1282
24 changed files with 248 additions and 72 deletions

View File

@@ -4,36 +4,86 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction import androidx.room.Transaction
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity
import org.koitharu.kotatsu.tracker.data.TrackLogWithManga import org.koitharu.kotatsu.tracker.data.TrackLogWithManga
@Dao @Dao
interface TrackLogsDao { abstract class TrackLogsDao {
@Transaction fun observeAll(limit: Int, filterOptions: Set<ListFilterOption>): Flow<List<TrackLogWithManga>> {
@Query("SELECT * FROM track_logs ORDER BY created_at DESC LIMIT :limit OFFSET 0") val query = buildString {
fun observeAll(limit: Int): Flow<List<TrackLogWithManga>> append("SELECT * FROM track_logs")
if (filterOptions.isNotEmpty()) {
append(" WHERE")
var isFirst = true
val groupedOptions = filterOptions.groupBy { it.groupKey }
for ((_, group) in groupedOptions) {
if (group.isEmpty()) {
continue
}
if (isFirst) {
isFirst = false
append(' ')
} else {
append(" AND ")
}
if (group.size > 1) {
group.joinTo(this, separator = " OR ", prefix = "(", postfix = ")") {
it.getCondition()
}
} else {
append(group.single().getCondition())
}
}
}
append(" ORDER BY created_at DESC")
if (limit > 0) {
append(" LIMIT ")
append(limit)
}
}
return observeAllImpl(SimpleSQLiteQuery(query))
}
@Query("SELECT COUNT(*) FROM track_logs WHERE unread = 1") @Query("SELECT COUNT(*) FROM track_logs WHERE unread = 1")
fun observeUnreadCount(): Flow<Int> abstract fun observeUnreadCount(): Flow<Int>
@Query("DELETE FROM track_logs") @Query("DELETE FROM track_logs")
suspend fun clear() abstract suspend fun clear()
@Query("UPDATE track_logs SET unread = 0 WHERE id = :id") @Query("UPDATE track_logs SET unread = 0 WHERE id = :id")
suspend fun markAsRead(id: Long) abstract suspend fun markAsRead(id: Long)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(entity: TrackLogEntity): Long abstract suspend fun insert(entity: TrackLogEntity): Long
@Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)") @Query("DELETE FROM track_logs WHERE manga_id NOT IN (SELECT manga_id FROM tracks)")
suspend fun gc() abstract suspend fun gc()
@Query("DELETE FROM track_logs WHERE id IN (SELECT id FROM track_logs ORDER BY created_at DESC LIMIT 0 OFFSET :size)") @Query("DELETE FROM track_logs WHERE id IN (SELECT id FROM track_logs ORDER BY created_at DESC LIMIT 0 OFFSET :size)")
suspend fun trim(size: Int) abstract suspend fun trim(size: Int)
@Query("SELECT COUNT(*) FROM track_logs") @Query("SELECT COUNT(*) FROM track_logs")
suspend fun count(): Int abstract suspend fun count(): Int
@Transaction
@RawQuery(observedEntities = [TrackLogEntity::class])
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<TrackLogWithManga>>
private fun ListFilterOption.getCondition(): String = when (this) {
ListFilterOption.Macro.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = track_logs.manga_id)"
is ListFilterOption.Favorite -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = track_logs.manga_id AND favourites.category_id = ${category.id})"
ListFilterOption.Macro.COMPLETED -> TODO()
ListFilterOption.Macro.NEW_CHAPTERS -> TODO()
ListFilterOption.Macro.NSFW -> TODO()
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE manga_tags.manga_id = track_logs.manga_id AND tag_id = ${tag.toEntity().id})"
else -> throw IllegalArgumentException("Unsupported option $this")
}
} }

View File

@@ -85,6 +85,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getInt(KEY_GRID_SIZE_PAGES, 100) get() = prefs.getInt(KEY_GRID_SIZE_PAGES, 100)
set(value) = prefs.edit { putInt(KEY_GRID_SIZE_PAGES, value) } set(value) = prefs.edit { putInt(KEY_GRID_SIZE_PAGES, value) }
val isQuickFilterEnabled: Boolean
get() = prefs.getBoolean(KEY_QUICK_FILTER, true)
var historyListMode: ListMode var historyListMode: ListMode
get() = prefs.getEnumValue(KEY_LIST_MODE_HISTORY, listMode) get() = prefs.getEnumValue(KEY_LIST_MODE_HISTORY, listMode)
set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_HISTORY, value) } set(value) = prefs.edit { putEnumValue(KEY_LIST_MODE_HISTORY, value) }
@@ -696,6 +699,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_FEED_HEADER = "feed_header" const val KEY_FEED_HEADER = "feed_header"
const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types" const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types"
const val KEY_SOURCES_VERSION = "sources_version" const val KEY_SOURCES_VERSION = "sources_version"
const val KEY_QUICK_FILTER = "quick_filter"
// keys for non-persistent preferences // keys for non-persistent preferences
const val KEY_APP_VERSION = "app_version" const val KEY_APP_VERSION = "app_version"

View File

@@ -51,6 +51,9 @@ abstract class FavouriteCategoriesDao {
@Query("SELECT MAX(sort_key) FROM favourite_categories WHERE deleted_at = 0") @Query("SELECT MAX(sort_key) FROM favourite_categories WHERE deleted_at = 0")
protected abstract suspend fun getMaxSortKey(): Int? protected abstract suspend fun getMaxSortKey(): Int?
@Query("SELECT favourite_categories.*, (SELECT SUM(chapters_new) FROM tracks WHERE tracks.manga_id IN (SELECT manga_id FROM favourites WHERE favourites.category_id = favourite_categories.category_id)) AS new_chapters FROM favourite_categories WHERE track = 1 AND show_in_lib = 1 AND deleted_at = 0 AND new_chapters > 0 ORDER BY new_chapters DESC LIMIT :limit")
abstract suspend fun getMostUpdatedCategories(limit: Int): List<FavouriteCategoryEntity>
suspend fun getNextSortKey(): Int { suspend fun getNextSortKey(): Int {
return (getMaxSortKey() ?: 0) + 1 return (getMaxSortKey() ?: 0) + 1
} }

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.favourites.domain.model.Cover import org.koitharu.kotatsu.favourites.domain.model.Cover
import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_COMPLETED
@Dao @Dao
abstract class FavouritesDao { abstract class FavouritesDao {
@@ -213,7 +214,7 @@ abstract class FavouritesDao {
} }
private fun ListFilterOption.getCondition(): String = when (this) { 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.COMPLETED -> "EXISTS(SELECT * FROM history WHERE history.manga_id = favourites.manga_id AND history.percent >= $PROGRESS_COMPLETED)"
ListFilterOption.Macro.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = favourites.manga_id) > 0" ListFilterOption.Macro.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = favourites.manga_id) > 0"
ListFilterOption.Macro.NSFW -> "manga.nsfw = 1" 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 = ${tag.toEntity().id})" is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE favourites.manga_id = manga_tags.manga_id AND tag_id = ${tag.toEntity().id})"

View File

@@ -10,7 +10,7 @@ class FavoritesListQuickFilter @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
private val repository: FavouritesRepository, private val repository: FavouritesRepository,
networkState: NetworkState, networkState: NetworkState,
) : MangaListQuickFilter() { ) : MangaListQuickFilter(settings) {
init { init {
setFilterOption(ListFilterOption.Downloaded, !networkState.value) setFilterOption(ListFilterOption.Downloaded, !networkState.value)

View File

@@ -238,6 +238,12 @@ class FavouritesRepository @Inject constructor(
.distinctUntilChanged() .distinctUntilChanged()
} }
suspend fun getMostUpdatedCategories(limit: Int): List<FavouriteCategory> {
return db.getFavouriteCategoriesDao().getMostUpdatedCategories(limit).map {
it.toFavouriteCategory()
}
}
private suspend fun recoverToFavourites(ids: Collection<Long>) { private suspend fun recoverToFavourites(ids: Collection<Long>) {
db.withTransaction { db.withTransaction {
for (id in ids) { for (id in ids) {

View File

@@ -86,7 +86,7 @@ class FavouritesListViewModel @Inject constructor(
list.isEmpty() -> if (filters.isEmpty()) { list.isEmpty() -> if (filters.isEmpty()) {
listOf(getEmptyState(hasFilters = false)) listOf(getEmptyState(hasFilters = false))
} else { } else {
listOf(quickFilter.filterItem(filters), getEmptyState(hasFilters = true)) listOfNotNull(quickFilter.filterItem(filters), getEmptyState(hasFilters = true))
} }
else -> { else -> {
@@ -146,7 +146,7 @@ class FavouritesListViewModel @Inject constructor(
this this
} }
val result = ArrayList<ListModel>(list.size + 1) val result = ArrayList<ListModel>(list.size + 1)
result += quickFilter.filterItem(filters) quickFilter.filterItem(filters)?.let(result::add)
mangaListMapper.toListModelList(result, list, mode) mangaListMapper.toListModelList(result, list, mode)
return result return result
} }

View File

@@ -13,6 +13,7 @@ import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.core.db.entity.toEntity import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_COMPLETED
@Dao @Dao
abstract class HistoryDao { abstract class HistoryDao {
@@ -172,7 +173,7 @@ abstract class HistoryDao {
private fun ListFilterOption.getCondition(): String = when (this) { private fun ListFilterOption.getCondition(): String = when (this) {
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})" 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.COMPLETED -> "percent >= $PROGRESS_COMPLETED"
ListFilterOption.Macro.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = history.manga_id) > 0" 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)" ListFilterOption.Macro.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE history.manga_id = favourites.manga_id)"
ListFilterOption.Macro.NSFW -> "manga.nsfw = 1" ListFilterOption.Macro.NSFW -> "manga.nsfw = 1"

View File

@@ -11,7 +11,7 @@ class HistoryListQuickFilter @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
private val repository: HistoryRepository, private val repository: HistoryRepository,
networkState: NetworkState, networkState: NetworkState,
) : MangaListQuickFilter() { ) : MangaListQuickFilter(settings) {
init { init {
setFilterOption(ListFilterOption.Downloaded, !networkState.value) setFilterOption(ListFilterOption.Downloaded, !networkState.value)

View File

@@ -101,7 +101,7 @@ class HistoryListViewModel @Inject constructor(
if (filters.isEmpty()) { if (filters.isEmpty()) {
listOf(getEmptyState(hasFilters = false)) listOf(getEmptyState(hasFilters = false))
} else { } else {
listOf(quickFilter.filterItem(filters), getEmptyState(hasFilters = true)) listOfNotNull(quickFilter.filterItem(filters), getEmptyState(hasFilters = true))
} }
} }
@@ -173,7 +173,7 @@ class HistoryListViewModel @Inject constructor(
historyList historyList
} }
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) + 2)
result += quickFilter.filterItem(filters) quickFilter.filterItem(filters)?.let(result::add)
if (isIncognito) { if (isIncognito) {
result += InfoModel( result += InfoModel(
key = AppSettings.KEY_INCOGNITO_MODE, key = AppSettings.KEY_INCOGNITO_MODE,

View File

@@ -3,11 +3,14 @@ package org.koitharu.kotatsu.list.domain
import androidx.collection.ArraySet import androidx.collection.ArraySet
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.list.ui.model.QuickFilter import org.koitharu.kotatsu.list.ui.model.QuickFilter
import org.koitharu.kotatsu.parsers.util.SuspendLazy import org.koitharu.kotatsu.parsers.util.SuspendLazy
abstract class MangaListQuickFilter : QuickFilterListener { abstract class MangaListQuickFilter(
private val settings: AppSettings,
) : QuickFilterListener {
private val appliedFilter = MutableStateFlow<Set<ListFilterOption>>(emptySet()) private val appliedFilter = MutableStateFlow<Set<ListFilterOption>>(emptySet())
private val availableFilterOptions = SuspendLazy { private val availableFilterOptions = SuspendLazy {
@@ -43,8 +46,11 @@ abstract class MangaListQuickFilter : QuickFilterListener {
suspend fun filterItem( suspend fun filterItem(
selectedOptions: Set<ListFilterOption>, selectedOptions: Set<ListFilterOption>,
) = QuickFilter( ): QuickFilter? {
items = availableFilterOptions.tryGet().getOrNull()?.map { option -> if (!settings.isQuickFilterEnabled) {
return null
}
val availableOptions = availableFilterOptions.tryGet().getOrNull()?.map { option ->
ChipsView.ChipModel( ChipsView.ChipModel(
title = option.titleText, title = option.titleText,
titleResId = option.titleResId, titleResId = option.titleResId,
@@ -53,8 +59,13 @@ abstract class MangaListQuickFilter : QuickFilterListener {
isChecked = option in selectedOptions, isChecked = option in selectedOptions,
data = option, data = option,
) )
}.orEmpty(), }.orEmpty()
) return if (availableOptions.isNotEmpty()) {
QuickFilter(availableOptions)
} else {
null
}
}
protected abstract suspend fun getAvailableFilterOptions(): List<ListFilterOption> protected abstract suspend fun getAvailableFilterOptions(): List<ListFilterOption>
} }

View File

@@ -64,7 +64,9 @@ abstract class MangaListViewModel(
protected fun observeListModeWithTriggers(): Flow<ListMode> = combine( protected fun observeListModeWithTriggers(): Flow<ListMode> = combine(
listMode, listMode,
settings.observe().filter { key -> settings.observe().filter { key ->
key == AppSettings.KEY_PROGRESS_INDICATORS || key == AppSettings.KEY_TRACKER_ENABLED key == AppSettings.KEY_PROGRESS_INDICATORS
|| key == AppSettings.KEY_TRACKER_ENABLED
|| key == AppSettings.KEY_QUICK_FILTER
}.onStart { emit("") }, }.onStart { emit("") },
) { mode, _ -> ) { mode, _ ->
mode mode

View File

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

View File

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

View File

@@ -32,20 +32,20 @@ class TypedListSpacingDecoration(
ListItemType.FILTER_TAG_MULTI, ListItemType.FILTER_TAG_MULTI,
ListItemType.FILTER_STATE, ListItemType.FILTER_STATE,
ListItemType.FILTER_LANGUAGE, ListItemType.FILTER_LANGUAGE,
ListItemType.FILTER_HEADER, ListItemType.QUICK_FILTER,
-> outRect.set(0) -> outRect.set(0)
ListItemType.HEADER, ListItemType.HEADER,
ListItemType.FEED, ListItemType.FEED,
ListItemType.EXPLORE_SOURCE_LIST, ListItemType.EXPLORE_SOURCE_LIST,
ListItemType.MANGA_SCROBBLING, ListItemType.MANGA_SCROBBLING,
ListItemType.MANGA_LIST, ListItemType.MANGA_LIST,
-> outRect.set(0) -> outRect.set(0)
ListItemType.DOWNLOAD, ListItemType.DOWNLOAD,
ListItemType.HINT_EMPTY, ListItemType.HINT_EMPTY,
ListItemType.MANGA_LIST_DETAILED, ListItemType.MANGA_LIST_DETAILED,
-> outRect.set(spacingNormal) -> outRect.set(spacingNormal)
ListItemType.PAGE_THUMB -> outRect.set(spacingNormal) ListItemType.PAGE_THUMB -> outRect.set(spacingNormal)
ListItemType.MANGA_GRID -> outRect.set(0) ListItemType.MANGA_GRID -> outRect.set(0)
@@ -65,7 +65,7 @@ class TypedListSpacingDecoration(
ListItemType.CHAPTER_LIST, ListItemType.CHAPTER_LIST,
ListItemType.INFO, ListItemType.INFO,
null, null,
-> outRect.set(0) -> outRect.set(0)
ListItemType.CHAPTER_GRID -> outRect.set(spacingSmall) ListItemType.CHAPTER_GRID -> outRect.set(spacingSmall)

View File

@@ -2,9 +2,14 @@ package org.koitharu.kotatsu.tracker.data
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Query import androidx.room.Query
import androidx.room.RawQuery
import androidx.room.Transaction import androidx.room.Transaction
import androidx.room.Upsert import androidx.room.Upsert
import androidx.sqlite.db.SimpleSQLiteQuery
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.list.domain.ListFilterOption
@Dao @Dao
abstract class TracksDao { abstract class TracksDao {
@@ -39,9 +44,33 @@ abstract class TracksDao {
@Query("SELECT * FROM tracks WHERE chapters_new > 0 ORDER BY last_chapter_date DESC") @Query("SELECT * FROM tracks WHERE chapters_new > 0 ORDER BY last_chapter_date DESC")
abstract fun observeUpdatedManga(): Flow<List<MangaWithTrack>> abstract fun observeUpdatedManga(): Flow<List<MangaWithTrack>>
@Transaction fun observeUpdatedManga(limit: Int, filterOptions: Set<ListFilterOption>): Flow<List<MangaWithTrack>> {
@Query("SELECT * FROM tracks WHERE chapters_new > 0 ORDER BY last_chapter_date DESC LIMIT :limit") val query = buildString {
abstract fun observeUpdatedManga(limit: Int): Flow<List<MangaWithTrack>> append("SELECT * FROM tracks WHERE chapters_new > 0")
if (filterOptions.isNotEmpty()) {
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(" ORDER BY last_chapter_date DESC")
if (limit > 0) {
append(" LIMIT ")
append(limit)
}
}
return observeMangaImpl(SimpleSQLiteQuery(query))
}
@Query("DELETE FROM tracks") @Query("DELETE FROM tracks")
abstract suspend fun clear() abstract suspend fun clear()
@@ -60,4 +89,18 @@ abstract class TracksDao {
@Upsert @Upsert
abstract suspend fun upsert(entity: TrackEntity) abstract suspend fun upsert(entity: TrackEntity)
@Transaction
@RawQuery(observedEntities = [TrackEntity::class])
protected abstract fun observeMangaImpl(query: SupportSQLiteQuery): Flow<List<MangaWithTrack>>
private fun ListFilterOption.getCondition(): String = when (this) {
ListFilterOption.Macro.FAVORITE -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = tracks.manga_id)"
is ListFilterOption.Favorite -> "EXISTS(SELECT * FROM favourites WHERE favourites.manga_id = tracks.manga_id AND favourites.category_id = ${category.id})"
ListFilterOption.Macro.COMPLETED -> TODO()
ListFilterOption.Macro.NEW_CHAPTERS -> TODO()
ListFilterOption.Macro.NSFW -> TODO()
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE manga_tags.manga_id = tracks.manga_id AND tag_id = ${tag.toEntity().id})"
else -> throw IllegalArgumentException("Unsupported option $this")
}
} }

View File

@@ -5,7 +5,6 @@ import androidx.room.withTransaction
import dagger.Reusable import dagger.Reusable
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
@@ -16,6 +15,7 @@ import org.koitharu.kotatsu.core.util.ext.ifZero
import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.core.util.ext.mapItems
import org.koitharu.kotatsu.core.util.ext.toInstantOrNull import org.koitharu.kotatsu.core.util.ext.toInstantOrNull
import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase import org.koitharu.kotatsu.details.domain.ProgressUpdateUseCase
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.tracker.data.TrackEntity import org.koitharu.kotatsu.tracker.data.TrackEntity
import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity
@@ -56,20 +56,17 @@ class TrackingRepository @Inject constructor(
return db.getTrackLogsDao().observeUnreadCount() return db.getTrackLogsDao().observeUnreadCount()
} }
fun observeUpdatedManga(limit: Int = 0): Flow<List<MangaTracking>> { fun observeUpdatedManga(limit: Int, filterOptions: Set<ListFilterOption>): Flow<List<MangaTracking>> {
return if (limit == 0) { return db.getTracksDao().observeUpdatedManga(limit, filterOptions)
db.getTracksDao().observeUpdatedManga() .mapItems {
} else { MangaTracking(
db.getTracksDao().observeUpdatedManga(limit) manga = it.manga.toManga(it.tags.toMangaTags()),
}.mapItems { lastChapterId = it.track.lastChapterId,
MangaTracking( lastCheck = it.track.lastCheckTime.toInstantOrNull(),
manga = it.manga.toManga(it.tags.toMangaTags()), lastChapterDate = it.track.lastChapterDate.toInstantOrNull(),
lastChapterId = it.track.lastChapterId, newChapters = it.track.newChapters,
lastCheck = it.track.lastCheckTime.toInstantOrNull(), )
lastChapterDate = it.track.lastChapterDate.toInstantOrNull(), }.distinctUntilChanged()
newChapters = it.track.newChapters,
)
}.distinctUntilChanged()
.onStart { gcIfNotCalled() } .onStart { gcIfNotCalled() }
} }
@@ -112,13 +109,10 @@ class TrackingRepository @Inject constructor(
db.getTracksDao().delete(mangaId) db.getTracksDao().delete(mangaId)
} }
fun observeTrackingLog(limit: Flow<Int>): Flow<List<TrackingLogItem>> { fun observeTrackingLog(limit: Int, filterOptions: Set<ListFilterOption>): Flow<List<TrackingLogItem>> {
return limit.flatMapLatest { limitValue -> return db.getTrackLogsDao().observeAll(limit, filterOptions)
db.getTrackLogsDao().observeAll(limitValue) .mapItems { it.toTrackingLogItem() }
.mapItems { it.toTrackingLogItem() } .onStart { gcIfNotCalled() }
}.onStart {
gcIfNotCalled()
}
} }
suspend fun getLogsCount() = db.getTrackLogsDao().count() suspend fun getLogsCount() = db.getTrackLogsDao().count()
@@ -217,7 +211,7 @@ class TrackingRepository @Inject constructor(
size - ids.size size - ids.size
} }
suspend fun getOrCreateTrack(mangaId: Long): TrackEntity { private suspend fun getOrCreateTrack(mangaId: Long): TrackEntity {
return db.getTracksDao().find(mangaId) ?: TrackEntity.create(mangaId) return db.getTracksDao().find(mangaId) ?: TrackEntity.create(mangaId)
} }

View File

@@ -0,0 +1,20 @@
package org.koitharu.kotatsu.tracker.domain
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.MangaListQuickFilter
import javax.inject.Inject
class UpdatesListQuickFilter @Inject constructor(
private val favouritesRepository: FavouritesRepository,
settings: AppSettings,
) : MangaListQuickFilter(settings) {
override suspend fun getAvailableFilterOptions(): List<ListFilterOption> =
favouritesRepository.getMostUpdatedCategories(
limit = 4,
).map {
ListFilterOption.Favorite(it)
}
}

View File

@@ -95,7 +95,7 @@ class FeedFragment :
viewModel.update() viewModel.update()
} }
override fun onFilterOptionClick(option: ListFilterOption) = Unit override fun onFilterOptionClick(option: ListFilterOption) = viewModel.toggleFilterOption(option)
override fun onRetryClick(error: Throwable) = Unit override fun onRetryClick(error: Throwable) = Unit

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
@@ -21,11 +22,14 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.domain.MangaListMapper
import org.koitharu.kotatsu.list.domain.QuickFilterListener
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.domain.UpdatesListQuickFilter
import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem import org.koitharu.kotatsu.tracker.domain.model.TrackingLogItem
import org.koitharu.kotatsu.tracker.ui.feed.model.FeedItem import org.koitharu.kotatsu.tracker.ui.feed.model.FeedItem
import org.koitharu.kotatsu.tracker.ui.feed.model.UpdatedMangaHeader import org.koitharu.kotatsu.tracker.ui.feed.model.UpdatedMangaHeader
@@ -42,7 +46,8 @@ class FeedViewModel @Inject constructor(
private val repository: TrackingRepository, private val repository: TrackingRepository,
private val scheduler: TrackWorker.Scheduler, private val scheduler: TrackWorker.Scheduler,
private val mangaListMapper: MangaListMapper, private val mangaListMapper: MangaListMapper,
) : BaseViewModel() { private val quickFilter: UpdatesListQuickFilter,
) : BaseViewModel(), QuickFilterListener by quickFilter {
private val limit = MutableStateFlow(PAGE_SIZE) private val limit = MutableStateFlow(PAGE_SIZE)
private val isReady = AtomicBoolean(false) private val isReady = AtomicBoolean(false)
@@ -57,11 +62,16 @@ class FeedViewModel @Inject constructor(
) )
val onFeedCleared = MutableEventFlow<Unit>() val onFeedCleared = MutableEventFlow<Unit>()
@Suppress("USELESS_CAST")
val content = combine( val content = combine(
observeHeader(), observeHeader(),
repository.observeTrackingLog(limit), quickFilter.appliedOptions,
) { header, list -> combine(limit, quickFilter.appliedOptions, ::Pair)
val result = ArrayList<ListModel>((list.size * 1.4).toInt().coerceAtLeast(2)) .flatMapLatest { repository.observeTrackingLog(it.first, it.second) },
) { header, filters, list ->
val result = ArrayList<ListModel>((list.size * 1.4).toInt().coerceAtLeast(3))
quickFilter.filterItem(filters)?.let(result::add)
if (header != null) { if (header != null) {
result += header result += header
} }
@@ -76,7 +86,9 @@ class FeedViewModel @Inject constructor(
isReady.set(true) isReady.set(true)
list.mapListTo(result) list.mapListTo(result)
} }
result result as List<ListModel>
}.catch { e ->
emit(listOf(e.toErrorState(canRetry = false)))
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
init { init {
@@ -129,7 +141,9 @@ class FeedViewModel @Inject constructor(
private fun observeHeader() = isHeaderEnabled.flatMapLatest { hasHeader -> private fun observeHeader() = isHeaderEnabled.flatMapLatest { hasHeader ->
if (hasHeader) { if (hasHeader) {
repository.observeUpdatedManga(10).map { mangaList -> quickFilter.appliedOptions.flatMapLatest {
repository.observeUpdatedManga(10, it)
}.map { mangaList ->
if (mangaList.isEmpty()) { if (mangaList.isEmpty()) {
null null
} else { } else {

View File

@@ -14,6 +14,7 @@ import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD
import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD import org.koitharu.kotatsu.list.ui.adapter.listHeaderAD
import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD
import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.adapter.quickFilterAD
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver import org.koitharu.kotatsu.list.ui.size.ItemSizeResolver
import org.koitharu.kotatsu.tracker.ui.feed.model.FeedItem import org.koitharu.kotatsu.tracker.ui.feed.model.FeedItem
@@ -44,6 +45,7 @@ class FeedAdapter(
addDelegate(ListItemType.STATE_ERROR, errorStateListAD(listener)) addDelegate(ListItemType.STATE_ERROR, errorStateListAD(listener))
addDelegate(ListItemType.HEADER, listHeaderAD(listener)) addDelegate(ListItemType.HEADER, listHeaderAD(listener))
addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener)) addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener))
addDelegate(ListItemType.QUICK_FILTER, quickFilterAD(listener))
} }
override fun getSectionText(context: Context, position: Int): CharSequence? { override fun getSectionText(context: Context, position: Int): CharSequence? {

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
@@ -17,7 +18,9 @@ import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo
import org.koitharu.kotatsu.core.util.ext.onFirst import org.koitharu.kotatsu.core.util.ext.onFirst
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.MangaListMapper 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.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
@@ -25,6 +28,7 @@ import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.list.ui.model.LoadingState import org.koitharu.kotatsu.list.ui.model.LoadingState
import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.list.ui.model.toErrorState
import org.koitharu.kotatsu.tracker.domain.TrackingRepository import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.tracker.domain.UpdatesListQuickFilter
import org.koitharu.kotatsu.tracker.domain.model.MangaTracking import org.koitharu.kotatsu.tracker.domain.model.MangaTracking
import javax.inject.Inject import javax.inject.Inject
@@ -33,16 +37,24 @@ class UpdatesViewModel @Inject constructor(
private val repository: TrackingRepository, private val repository: TrackingRepository,
settings: AppSettings, settings: AppSettings,
private val mangaListMapper: MangaListMapper, private val mangaListMapper: MangaListMapper,
private val quickFilter: UpdatesListQuickFilter,
downloadScheduler: DownloadWorker.Scheduler, downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler) { ) : MangaListViewModel(settings, downloadScheduler), QuickFilterListener by quickFilter {
override val content = combine( override val content = combine(
repository.observeUpdatedManga(), quickFilter.appliedOptions.flatMapLatest { filterOptions ->
repository.observeUpdatedManga(
limit = 0,
filterOptions = filterOptions,
)
},
quickFilter.appliedOptions,
settings.observeAsFlow(AppSettings.KEY_UPDATED_GROUPING) { isUpdatedGroupingEnabled }, settings.observeAsFlow(AppSettings.KEY_UPDATED_GROUPING) { isUpdatedGroupingEnabled },
observeListModeWithTriggers(), observeListModeWithTriggers(),
) { mangaList, grouping, mode -> ) { mangaList, filters, grouping, mode ->
when { when {
mangaList.isEmpty() -> listOf( mangaList.isEmpty() -> listOfNotNull(
quickFilter.filterItem(filters),
EmptyState( EmptyState(
icon = R.drawable.ic_empty_history, icon = R.drawable.ic_empty_history,
textPrimary = R.string.text_history_holder_primary, textPrimary = R.string.text_history_holder_primary,
@@ -51,7 +63,7 @@ class UpdatesViewModel @Inject constructor(
), ),
) )
else -> mangaList.toUi(mode, grouping) else -> mangaList.toUi(mode, filters, grouping)
} }
}.onStart { }.onStart {
loadingCounter.increment() loadingCounter.increment()
@@ -77,8 +89,13 @@ class UpdatesViewModel @Inject constructor(
} }
} }
private suspend fun List<MangaTracking>.toUi(mode: ListMode, grouped: Boolean): List<ListModel> { private suspend fun List<MangaTracking>.toUi(
val result = ArrayList<ListModel>(if (grouped) (size * 1.4).toInt() else size) mode: ListMode,
filters: Set<ListFilterOption>,
grouped: Boolean,
): List<ListModel> {
val result = ArrayList<ListModel>(if (grouped) (size * 1.4).toInt() else size + 1)
quickFilter.filterItem(filters)?.let(result::add)
var prevHeader: DateTimeAgo? = null var prevHeader: DateTimeAgo? = null
for (item in this) { for (item in this) {
if (grouped) { if (grouped) {

View File

@@ -673,4 +673,6 @@
<string name="plugin_incompatible">Incompatible plugin or internal error. Make sure you are using the latest version of the plugin and Kotatsu</string> <string name="plugin_incompatible">Incompatible plugin or internal error. Make sure you are using the latest version of the plugin and Kotatsu</string>
<string name="connection_ok">Connection is OK</string> <string name="connection_ok">Connection is OK</string>
<string name="invalid_proxy_configuration">Invalid proxy configuration</string> <string name="invalid_proxy_configuration">Invalid proxy configuration</string>
<string name="show_quick_filters">Show quick filters</string>
<string name="show_quick_filters_summary">Provides the ability to filter manga lists by certain parameters</string>
</resources> </resources>

View File

@@ -38,6 +38,12 @@
android:valueTo="150" android:valueTo="150"
app:defaultValue="100" /> app:defaultValue="100" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="quick_filter"
android:summary="@string/show_quick_filters_summary"
android:title="@string/show_quick_filters" />
<ListPreference <ListPreference
android:entries="@array/progress_indicators" android:entries="@array/progress_indicators"
android:key="progress_indicators" android:key="progress_indicators"