From 604efef832cfb609ba6d37e9d91286e8223d3ad4 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Tue, 18 Feb 2025 15:20:25 +0200 Subject: [PATCH] Improve global search --- .../kotatsu/favourites/data/FavouritesDao.kt | 10 +++- .../favourites/domain/FavouritesRepository.kt | 15 ++++- .../kotatsu/history/data/HistoryDao.kt | 10 +++- .../kotatsu/history/data/HistoryRepository.kt | 15 ++++- .../kotatsu/search/ui/multi/SearchActivity.kt | 2 + .../search/ui/multi/SearchKindMenuProvider.kt | 56 +++++++++++++++++++ .../search/ui/multi/SearchViewModel.kt | 46 ++++++++------- app/src/main/res/layout/item_manga_grid.xml | 2 +- .../res/layout/item_manga_list_details.xml | 2 +- app/src/main/res/menu/opt_search_kind.xml | 40 +++++++++++++ app/src/main/res/values/strings.xml | 1 + 11 files changed, 168 insertions(+), 31 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchKindMenuProvider.kt create mode 100644 app/src/main/res/menu/opt_search_kind.xml diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt index fd8376682..647ac9eaa 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt @@ -35,7 +35,15 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback { @Transaction @Query("SELECT manga.* FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.deleted_at = 0 AND (manga.title LIKE :query OR manga.alt_title LIKE :query) LIMIT :limit") - abstract suspend fun search(query: String, limit: Int): List + abstract suspend fun searchByTitle(query: String, limit: Int): List + + @Transaction + @Query("SELECT manga.* FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.deleted_at = 0 AND (manga.author LIKE :query) LIMIT :limit") + abstract suspend fun searchByAuthor(query: String, limit: Int): List + + @Transaction + @Query("SELECT manga.* FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.deleted_at = 0 AND EXISTS(SELECT 1 FROM tags LEFT JOIN manga_tags ON manga_tags.tag_id = tags.tag_id WHERE manga_tags.manga_id = manga.manga_id AND tags.title LIKE :query) LIMIT :limit") + abstract suspend fun searchByTag(query: String, limit: Int): List fun observeAll( order: ListSortOrder, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt index 7e7f1f049..f81b1aa50 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt @@ -25,6 +25,7 @@ import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.levenshteinDistance +import org.koitharu.kotatsu.search.domain.SearchKind import javax.inject.Inject @Reusable @@ -43,9 +44,17 @@ class FavouritesRepository @Inject constructor( return entities.toMangaList() } - suspend fun search(query: String, limit: Int): List { - val entities = db.getFavouritesDao().search("%$query%", limit) - return entities.toMangaList().sortedBy { it.title.levenshteinDistance(query) } + suspend fun search(query: String, kind: SearchKind, limit: Int): List { + val dao = db.getFavouritesDao() + val q = "%$query%" + val entities = when (kind) { + SearchKind.SIMPLE, + SearchKind.TITLE -> dao.searchByTitle(q, limit).sortedBy { it.manga.title.levenshteinDistance(query) } + + SearchKind.AUTHOR -> dao.searchByAuthor(q, limit) + SearchKind.TAG -> dao.searchByTag(q, limit) + } + return entities.toMangaList() } fun observeAll(order: ListSortOrder, filterOptions: Set, limit: Int): Flow> { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt index dbe45dc9f..7f2561929 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt @@ -26,7 +26,15 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback { @Transaction @Query("SELECT manga.* FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id WHERE history.deleted_at = 0 AND (manga.title LIKE :query OR manga.alt_title LIKE :query) LIMIT :limit") - abstract suspend fun search(query: String, limit: Int): List + abstract suspend fun searchByTitle(query: String, limit: Int): List + + @Transaction + @Query("SELECT manga.* FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id WHERE history.deleted_at = 0 AND (manga.author LIKE :query) LIMIT :limit") + abstract suspend fun searchByAuthor(query: String, limit: Int): List + + @Transaction + @Query("SELECT manga.* FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id WHERE history.deleted_at = 0 AND EXISTS(SELECT 1 FROM tags LEFT JOIN manga_tags ON manga_tags.tag_id = tags.tag_id WHERE manga_tags.manga_id = manga.manga_id AND tags.title LIKE :query) LIMIT :limit") + abstract suspend fun searchByTag(query: String, limit: Int): List @Transaction @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC") diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt index 13aaf137b..01fcf35b5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt @@ -33,6 +33,7 @@ import org.koitharu.kotatsu.parsers.util.findById import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble +import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase import javax.inject.Inject import javax.inject.Provider @@ -52,9 +53,17 @@ class HistoryRepository @Inject constructor( return entities.map { it.toManga() } } - suspend fun search(query: String, limit: Int): List { - val entities = db.getHistoryDao().search("%$query%", limit) - return entities.toMangaList().sortedBy { it.title.levenshteinDistance(query) } + suspend fun search(query: String, kind: SearchKind, limit: Int): List { + val dao = db.getHistoryDao() + val q = "%$query%" + val entities = when (kind) { + SearchKind.SIMPLE, + SearchKind.TITLE -> dao.searchByTitle(q, limit).sortedBy { it.manga.title.levenshteinDistance(query) } + + SearchKind.AUTHOR -> dao.searchByAuthor(q, limit) + SearchKind.TAG -> dao.searchByTag(q, limit) + } + return entities.toMangaList() } suspend fun getCount(): Int { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt index 3f3f0917d..6ef2588fd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt @@ -99,6 +99,8 @@ class SearchActivity : setSubtitle(R.string.search_results) } + addMenuProvider(SearchKindMenuProvider(this, viewModel.query, viewModel.kind)) + viewModel.list.observe(this, adapter) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchKindMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchKindMenuProvider.kt new file mode 100644 index 000000000..47f70e6f0 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchKindMenuProvider.kt @@ -0,0 +1,56 @@ +package org.koitharu.kotatsu.search.ui.multi + +import android.os.Build +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.core.view.MenuProvider +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router +import org.koitharu.kotatsu.search.domain.SearchKind + +class SearchKindMenuProvider( + private val activity: SearchActivity, + private val query: String, + private val kind: SearchKind +) : MenuProvider { + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.opt_search_kind, menu) + } + + override fun onPrepareMenu(menu: Menu) { + super.onPrepareMenu(menu) + menu.findItem( + when (kind) { + SearchKind.SIMPLE -> R.id.action_kind_simple + SearchKind.TITLE -> R.id.action_kind_title + SearchKind.AUTHOR -> R.id.action_kind_author + SearchKind.TAG -> R.id.action_kind_tag + }, + )?.isChecked = true + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + val newKind = when (menuItem.itemId) { + R.id.action_kind_simple -> SearchKind.SIMPLE + R.id.action_kind_title -> SearchKind.TITLE + R.id.action_kind_author -> SearchKind.AUTHOR + R.id.action_kind_tag -> SearchKind.TAG + else -> return false + } + if (newKind != kind) { + activity.router.openSearch( + query = query, + kind = newKind, + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out, 0) + } else { + activity.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) + } + activity.finishAfterTransition() + } + return true + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt index 4ba42c203..fa53187d6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt @@ -37,10 +37,7 @@ import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaListFilter -import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.domain.SearchV2Helper @@ -56,7 +53,6 @@ class SearchViewModel @Inject constructor( private val searchHelperFactory: SearchV2Helper.Factory, private val sourcesRepository: MangaSourcesRepository, private val historyRepository: HistoryRepository, - private val localMangaRepository: LocalMangaRepository, private val favouritesRepository: FavouritesRepository, ) : BaseViewModel() { @@ -65,7 +61,7 @@ class SearchViewModel @Inject constructor( private val retryCounter = MutableStateFlow(0) private val listData = retryCounter.flatMapLatest { - searchImpl(query).withLoading().withErrorHandling() + searchImpl().withLoading().withErrorHandling() }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) val list: StateFlow> = combine( @@ -108,10 +104,10 @@ class SearchViewModel @Inject constructor( } @CheckResult - private fun searchImpl(q: String): Flow> = channelFlow { - searchHistory(q)?.let { send(it) } - searchFavorites(q)?.let { send(it) } - searchLocal(q)?.let { send(it) } + private fun searchImpl(): Flow> = channelFlow { + searchHistory()?.let { send(it) } + searchFavorites()?.let { send(it) } + searchLocal()?.let { send(it) } val sources = sourcesRepository.getEnabledSources() if (sources.isEmpty()) { return@channelFlow @@ -158,9 +154,9 @@ class SearchViewModel @Inject constructor( .filterNotNull() .onEmpty { emit(emptyList()) } - private suspend fun searchHistory(q: String): SearchResultsListModel? { + private suspend fun searchHistory(): SearchResultsListModel? { return runCatchingCancellable { - historyRepository.search(q, Int.MAX_VALUE) + historyRepository.search(query, kind, Int.MAX_VALUE) }.fold( onSuccess = { result -> if (result.isNotEmpty()) { @@ -191,9 +187,9 @@ class SearchViewModel @Inject constructor( ) } - private suspend fun searchFavorites(q: String): SearchResultsListModel? { + private suspend fun searchFavorites(): SearchResultsListModel? { return runCatchingCancellable { - favouritesRepository.search(q, Int.MAX_VALUE) + favouritesRepository.search(query, kind, Int.MAX_VALUE) }.fold( onSuccess = { result -> if (result.isNotEmpty()) { @@ -201,7 +197,11 @@ class SearchViewModel @Inject constructor( titleResId = R.string.favourites, source = UnknownMangaSource, hasMore = false, - list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), + list = mangaListMapper.toListModelList( + manga = result, + mode = ListMode.GRID, + flags = MangaListMapper.NO_FAVORITE, + ), error = null, listFilter = null, sortOrder = null, @@ -224,20 +224,24 @@ class SearchViewModel @Inject constructor( ) } - private suspend fun searchLocal(q: String): SearchResultsListModel? { + private suspend fun searchLocal(): SearchResultsListModel? { return runCatchingCancellable { - localMangaRepository.getList(0, SortOrder.RELEVANCE, MangaListFilter(query = q)) + searchHelperFactory.create(LocalMangaSource).invoke(query, kind) }.fold( onSuccess = { result -> - if (result.isNotEmpty()) { + if (!result?.manga.isNullOrEmpty()) { SearchResultsListModel( titleResId = 0, source = LocalMangaSource, - hasMore = result.size > MIN_HAS_MORE_ITEMS, - list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), + hasMore = result.manga.size > MIN_HAS_MORE_ITEMS, + list = mangaListMapper.toListModelList( + manga = result.manga, + mode = ListMode.GRID, + flags = MangaListMapper.NO_SAVED, + ), error = null, - listFilter = null, - sortOrder = null, + listFilter = result.listFilter, + sortOrder = result.sortOrder, ) } else { null diff --git a/app/src/main/res/layout/item_manga_grid.xml b/app/src/main/res/layout/item_manga_grid.xml index e5f7a3baa..6d55ccf21 100644 --- a/app/src/main/res/layout/item_manga_grid.xml +++ b/app/src/main/res/layout/item_manga_grid.xml @@ -39,7 +39,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="top|start" - android:layout_marginTop="@dimen/card_indicator_offset" + android:layout_marginTop="10dp" android:background="@drawable/bg_list_icons" android:orientation="horizontal" android:padding="4dp" diff --git a/app/src/main/res/layout/item_manga_list_details.xml b/app/src/main/res/layout/item_manga_list_details.xml index 79c677eda..57371cd8a 100644 --- a/app/src/main/res/layout/item_manga_list_details.xml +++ b/app/src/main/res/layout/item_manga_list_details.xml @@ -36,7 +36,7 @@ android:id="@+id/iconsView" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="@dimen/card_indicator_offset" + android:layout_marginTop="10dp" android:background="@drawable/bg_list_icons" android:orientation="horizontal" android:padding="4dp" diff --git a/app/src/main/res/menu/opt_search_kind.xml b/app/src/main/res/menu/opt_search_kind.xml new file mode 100644 index 000000000..157dc309d --- /dev/null +++ b/app/src/main/res/menu/opt_search_kind.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dcd6d0e90..ac3c6f5c8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -802,4 +802,5 @@ Screen rotation has been unlocked Badges in lists Search everywhere + Simple