@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -23,5 +23,6 @@ class SearchSuggestionAdapter(
|
||||
.addDelegate(searchSuggestionMangaListAD(coil, lifecycleOwner, listener))
|
||||
.addDelegate(searchSuggestionQueryHintAD(listener))
|
||||
.addDelegate(searchSuggestionAuthorAD(listener))
|
||||
.addDelegate(searchSuggestionTextAD())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
14
app/src/main/res/layout/item_search_suggestion_text.xml
Normal file
14
app/src/main/res/layout/item_search_suggestion_text.xml
Normal 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" />
|
||||
Reference in New Issue
Block a user