Fix search suggestions

(cherry picked from commit 1a8045b89f)
This commit is contained in:
Koitharu
2025-04-27 15:28:48 +03:00
parent 9cf496b7c4
commit 4449996a91
9 changed files with 185 additions and 73 deletions

View File

@@ -37,7 +37,7 @@ class MangaSearchRepository @Inject constructor(
suspend fun getMangaSuggestion(query: String, limit: Int, source: MangaSource?): List<Manga> {
return when {
query.isEmpty() -> db.getSuggestionDao().getRandom(limit).map { MangaWithTags(it.manga, it.tags) }
query.isEmpty() -> db.getSuggestionDao().getRandom(limit).map { MangaWithTags(it.manga, emptyList()) }
source != null -> db.getMangaDao().searchByTitle("%$query%", source.name, limit)
else -> db.getMangaDao().searchByTitle("%$query%", limit)
}.let {

View File

@@ -9,11 +9,13 @@ import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.ItemTouchHelper
import coil3.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.os.VoiceInputContract
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets
import org.koitharu.kotatsu.core.util.ext.consumeAll
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.FragmentSearchSuggestionBinding
import org.koitharu.kotatsu.search.ui.suggestion.adapter.SearchSuggestionAdapter
import javax.inject.Inject
@@ -49,19 +51,16 @@ class SearchSuggestionFragment :
binding.root.adapter = adapter
binding.root.setHasFixedSize(true)
viewModel.suggestion.observe(viewLifecycleOwner, adapter)
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.root, this))
ItemTouchHelper(SearchSuggestionItemCallback(this))
.attachToRecyclerView(binding.root)
}
override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat {
val barsInsets = insets.getInsets(WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars())
v.setPadding(
barsInsets.left,
0,
barsInsets.right,
barsInsets.bottom,
)
return insets.consumeAllSystemBarsInsets()
val typeMask = WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars()
val barsInsets = insets.getInsets(typeMask)
v.setPadding(barsInsets.left, 0, barsInsets.right, barsInsets.bottom)
return insets.consumeAll(typeMask)
}
override fun onRemoveQuery(query: String) {

View File

@@ -21,11 +21,12 @@ import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.sizeOrZero
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import org.koitharu.kotatsu.search.domain.MangaSearchRepository
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
import javax.inject.Inject
@@ -87,7 +88,7 @@ class SearchSuggestionViewModel @Inject constructor(
}
fun onResume() {
if (invalidateOnResume) {
if (invalidateOnResume || suggestionJob?.isActive != true) {
invalidateOnResume = false
setupSuggestion()
}
@@ -120,62 +121,114 @@ class SearchSuggestionViewModel @Inject constructor(
enabledSources: Set<String>,
types: Set<SearchSuggestionType>,
): List<SearchSuggestionItem> = coroutineScope {
val queriesDeferred = if (SearchSuggestionType.QUERIES_RECENT in types) {
async { repository.getQuerySuggestion(searchQuery, MAX_QUERY_ITEMS) }
listOfNotNull(
if (SearchSuggestionType.GENRES in types) {
async { getTags(searchQuery) }
} else {
null
},
if (SearchSuggestionType.MANGA in types) {
async { getManga(searchQuery) }
} else {
null
},
if (SearchSuggestionType.QUERIES_RECENT in types) {
async { getRecentQueries(searchQuery) }
} else {
null
},
if (SearchSuggestionType.QUERIES_SUGGEST in types) {
async { getQueryHints(searchQuery) }
} else {
null
},
if (SearchSuggestionType.SOURCES in types) {
async { getSources(searchQuery, enabledSources) }
} else {
null
},
if (SearchSuggestionType.RECENT_SOURCES in types) {
async { getRecentSources(searchQuery) }
} else {
null
},
if (SearchSuggestionType.AUTHORS in types) {
async {
getAuthors(searchQuery)
}
} else {
null
},
).flatMap { it.await() }
}
private suspend fun getAuthors(searchQuery: String): List<SearchSuggestionItem> = runCatchingCancellable {
repository.getAuthorsSuggestion(searchQuery, MAX_AUTHORS_ITEMS)
.map { SearchSuggestionItem.Author(it) }
}.getOrElse { e ->
e.printStackTraceDebug()
listOf(SearchSuggestionItem.Text(0, e))
}
private suspend fun getQueryHints(searchQuery: String): List<SearchSuggestionItem> = runCatchingCancellable {
repository.getQueryHintSuggestion(searchQuery, MAX_HINTS_ITEMS)
.map { SearchSuggestionItem.Hint(it) }
}.getOrElse { e ->
e.printStackTraceDebug()
listOf(SearchSuggestionItem.Text(0, e))
}
private suspend fun getRecentQueries(searchQuery: String): List<SearchSuggestionItem> = runCatchingCancellable {
repository.getQuerySuggestion(searchQuery, MAX_QUERY_ITEMS)
.map { SearchSuggestionItem.RecentQuery(it) }
}.getOrElse { e ->
e.printStackTraceDebug()
listOf(SearchSuggestionItem.Text(0, e))
}
private suspend fun getTags(searchQuery: String): List<SearchSuggestionItem> = runCatchingCancellable {
val tags = repository.getTagsSuggestion(searchQuery, MAX_TAGS_ITEMS, null)
if (tags.isEmpty()) {
emptyList()
} else {
null
listOf(SearchSuggestionItem.Tags(mapTags(tags)))
}
val hintsDeferred = if (SearchSuggestionType.QUERIES_SUGGEST in types) {
async { repository.getQueryHintSuggestion(searchQuery, MAX_HINTS_ITEMS) }
}.getOrElse { e ->
e.printStackTraceDebug()
listOf(SearchSuggestionItem.Text(0, e))
}
private suspend fun getManga(searchQuery: String): List<SearchSuggestionItem> = runCatchingCancellable {
val manga = repository.getMangaSuggestion(searchQuery, MAX_MANGA_ITEMS, null)
if (manga.isEmpty()) {
emptyList()
} else {
null
listOf(SearchSuggestionItem.MangaList(manga))
}
val authorsDeferred = if (SearchSuggestionType.AUTHORS in types) {
async { repository.getAuthorsSuggestion(searchQuery, MAX_AUTHORS_ITEMS) }
} else {
null
}
val tagsDeferred = if (SearchSuggestionType.GENRES in types) {
async { repository.getTagsSuggestion(searchQuery, MAX_TAGS_ITEMS, null) }
} else {
null
}
val mangaDeferred = if (SearchSuggestionType.MANGA in types) {
async { repository.getMangaSuggestion(searchQuery, MAX_MANGA_ITEMS, null) }
} else {
null
}
val sources = if (SearchSuggestionType.SOURCES in types) {
}.getOrElse { e ->
e.printStackTraceDebug()
listOf(SearchSuggestionItem.Text(0, e))
}
private suspend fun getSources(searchQuery: String, enabledSources: Set<String>): List<SearchSuggestionItem> =
runCatchingCancellable {
repository.getSourcesSuggestion(searchQuery, MAX_SOURCES_ITEMS)
} else {
null
}
val sourcesTipsDeferred = if (searchQuery.isEmpty() && SearchSuggestionType.RECENT_SOURCES in types) {
async { repository.getSourcesSuggestion(MAX_SOURCES_TIPS_ITEMS) }
} else {
null
.map { SearchSuggestionItem.Source(it, it.name in enabledSources) }
}.getOrElse { e ->
e.printStackTraceDebug()
listOf(SearchSuggestionItem.Text(0, e))
}
val tags = tagsDeferred?.await()
val mangaList = mangaDeferred?.await()
val queries = queriesDeferred?.await()
val hints = hintsDeferred?.await()
val authors = authorsDeferred?.await()
val sourcesTips = sourcesTipsDeferred?.await()
buildList(queries.sizeOrZero() + sources.sizeOrZero() + authors.sizeOrZero() + hints.sizeOrZero() + 2) {
if (!tags.isNullOrEmpty()) {
add(SearchSuggestionItem.Tags(mapTags(tags)))
}
if (!mangaList.isNullOrEmpty()) {
add(SearchSuggestionItem.MangaList(mangaList))
}
sources?.mapTo(this) { SearchSuggestionItem.Source(it, it.name in enabledSources) }
queries?.mapTo(this) { SearchSuggestionItem.RecentQuery(it) }
authors?.mapTo(this) { SearchSuggestionItem.Author(it) }
hints?.mapTo(this) { SearchSuggestionItem.Hint(it) }
sourcesTips?.mapTo(this) { SearchSuggestionItem.SourceTip(it) }
private suspend fun getRecentSources(searchQuery: String): List<SearchSuggestionItem> = if (searchQuery.isEmpty()) {
runCatchingCancellable {
repository.getSourcesSuggestion(MAX_SOURCES_TIPS_ITEMS)
.map { SearchSuggestionItem.SourceTip(it) }
}.getOrElse { e ->
e.printStackTraceDebug()
listOf(SearchSuggestionItem.Text(0, e))
}
} else {
emptyList()
}
private fun mapTags(tags: List<MangaTag>): List<ChipsView.ChipModel> = tags.map { tag ->

View File

@@ -23,5 +23,6 @@ class SearchSuggestionAdapter(
.addDelegate(searchSuggestionMangaListAD(coil, lifecycleOwner, listener))
.addDelegate(searchSuggestionQueryHintAD(listener))
.addDelegate(searchSuggestionAuthorAD(listener))
.addDelegate(searchSuggestionTextAD())
}
}

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.search.ui.suggestion.adapter
import android.widget.TextView
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
fun searchSuggestionTextAD() = adapterDelegate<SearchSuggestionItem.Text, SearchSuggestionItem>(
R.layout.item_search_suggestion_text,
) {
bind {
val tv = itemView as TextView
val isError = item.error != null
tv.setCompoundDrawablesRelativeWithIntrinsicBounds(
if (isError) R.drawable.ic_error_small else 0,
0,
0,
0,
)
if (item.textResId != 0) {
tv.setText(item.textResId)
} else {
tv.text = item.error?.getDisplayMessage(tv.resources)
}
}
}

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.search.ui.suggestion.model
import androidx.annotation.StringRes
import org.koitharu.kotatsu.core.model.isNsfw
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
@@ -93,4 +94,15 @@ sealed interface SearchSuggestionItem : ListModel {
return ListModelDiffCallback.PAYLOAD_NESTED_LIST_CHANGED
}
}
data class Text(
@StringRes val textResId: Int,
val error: Throwable?,
) : SearchSuggestionItem {
override fun areItemsTheSame(other: ListModel): Boolean = other is Text
&& textResId == other.textResId
&& error?.javaClass == other.error?.javaClass
&& error?.message == other.error?.message
}
}

View File

@@ -11,6 +11,7 @@ import androidx.room.Update
import androidx.sqlite.db.SupportSQLiteQuery
import kotlinx.coroutines.flow.Flow
import org.koitharu.kotatsu.core.db.MangaQueryBuilder
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.list.domain.ListFilterOption
@@ -33,12 +34,10 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback {
)
@Transaction
@Query("SELECT * FROM suggestions ORDER BY RANDOM() LIMIT 1")
abstract suspend fun getRandom(): SuggestionWithManga?
@Transaction
@Query("SELECT * FROM suggestions ORDER BY RANDOM() LIMIT :limit")
abstract suspend fun getRandom(limit: Int): List<SuggestionWithManga>
open suspend fun getRandom(limit: Int): List<MangaWithTags> {
val ids = getRandomIds(limit)
return getByIds(ids)
}
@Query("SELECT COUNT(*) FROM suggestions")
abstract suspend fun count(): Int
@@ -68,6 +67,12 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback {
}
}
@Query("SELECT * FROM manga WHERE manga_id IN (:ids)")
protected abstract suspend fun getByIds(ids: LongArray): List<MangaWithTags>
@Query("SELECT manga_id FROM suggestions ORDER BY RANDOM() LIMIT :limit")
protected abstract suspend fun getRandomIds(limit: Int): LongArray
@Transaction
@RawQuery(observedEntities = [SuggestionEntity::class])
protected abstract fun observeAllImpl(query: SupportSQLiteQuery): Flow<List<SuggestionWithManga>>
@@ -75,7 +80,12 @@ abstract class SuggestionDao : MangaQueryBuilder.ConditionCallback {
override fun getCondition(option: ListFilterOption): String? = when (option) {
ListFilterOption.Macro.NSFW -> "(SELECT nsfw FROM manga WHERE manga.manga_id = suggestions.manga_id) = 1"
is ListFilterOption.Tag -> "EXISTS(SELECT * FROM manga_tags WHERE manga_tags.manga_id = suggestions.manga_id AND tag_id = ${option.tagId})"
is ListFilterOption.Source -> "(SELECT source FROM manga WHERE manga.manga_id = suggestions.manga_id) = ${sqlEscapeString(option.mangaSource.name)}"
is ListFilterOption.Source -> "(SELECT source FROM manga WHERE manga.manga_id = suggestions.manga_id) = ${
sqlEscapeString(
option.mangaSource.name,
)
}"
else -> null
}
}

View File

@@ -6,7 +6,6 @@ import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.db.entity.toEntities
import org.koitharu.kotatsu.core.db.entity.toEntity
import org.koitharu.kotatsu.core.db.entity.toManga
import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.db.entity.toMangaTagsList
import org.koitharu.kotatsu.core.model.toMangaSources
import org.koitharu.kotatsu.core.util.ext.mapItems
@@ -34,10 +33,6 @@ class SuggestionRepository @Inject constructor(
}
}
suspend fun getRandom(): Manga? {
return db.getSuggestionDao().getRandom()?.toManga()
}
suspend fun getRandomList(limit: Int): List<Manga> {
return db.getSuggestionDao().getRandom(limit).map {
it.toManga()
@@ -80,5 +75,5 @@ class SuggestionRepository @Inject constructor(
}
}
private fun SuggestionWithManga.toManga() = manga.toManga(tags.toMangaTags(), null)
private fun SuggestionWithManga.toManga() = manga.toManga(emptySet(), null)
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawablePadding="@dimen/screen_padding"
android:paddingVertical="@dimen/margin_small"
android:paddingStart="?listPreferredItemPaddingStart"
android:paddingEnd="?listPreferredItemPaddingEnd"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?android:textColorSecondary"
tools:drawableStart="@drawable/ic_error_small"
tools:text="@string/error_corrupted_file" />