diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d980fda3f..aeea32e40 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -12,9 +12,6 @@ Connectivity Settings - Matched name - Matched note - Matched name and note No matches found Coming soon diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDao.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDao.kt index 16a2799ad..8161157c8 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDao.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDao.kt @@ -49,14 +49,17 @@ internal interface ItemDao { ) SELECT i.id, i.name, i.item_type AS itemType, i.pinned, - (i.name LIKE '%' || :query || '%') AS matchedName, - (i.note LIKE '%' || :query || '%') AS matchedNote, - (tm.item_id IS NOT NULL) AS matchedTag + (i.name LIKE '%' || :query || '%') AS matchedName, + (i.note LIKE '%' || :query || '%') AS matchedNote, + (l.username LIKE '%' || :query || '%') AS matchedUsername, + (tm.item_id IS NOT NULL) AS matchedTag FROM item i LEFT JOIN tag_matches tm ON tm.item_id = i.id + LEFT JOIN login l ON l.id = i.id WHERE (:itemType IS NULL OR i.item_type = :itemType) AND (i.name LIKE '%' || :query || '%' OR i.note LIKE '%' || :query || '%' + OR l.username LIKE '%' || :query || '%' OR tm.item_id IS NOT NULL) """ ) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightItemSearchResult.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightItemSearchResult.kt index 42cb70e61..0dcb0af4c 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightItemSearchResult.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightItemSearchResult.kt @@ -9,6 +9,7 @@ internal data class LightweightItemSearchResult( val itemType: VaultItemType, val matchedName: Boolean, val matchedNote: Boolean, + val matchedUsername: Boolean, val matchedTag: Boolean, val pinned: Boolean ) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapper.kt index 0b97f4c98..2aafdafb9 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapper.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapper.kt @@ -34,6 +34,7 @@ internal fun LightweightItemSearchResult.toDomain() = LiteItemSearchResult( itemType = itemType, matchedName = matchedName, matchedNote = matchedNote, + matchedUsername = matchedUsername, matchedTag = matchedTag, pinned = pinned, ) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteItemSearchResult.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteItemSearchResult.kt index 330d175a8..8559b6cb8 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteItemSearchResult.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteItemSearchResult.kt @@ -10,5 +10,6 @@ data class LiteItemSearchResult( override val pinned: Boolean, val matchedName: Boolean, val matchedNote: Boolean, + val matchedUsername: Boolean, val matchedTag: Boolean, ) : LiteItem diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDaoSearchTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDaoSearchTest.kt index 45360c2da..36988cab6 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDaoSearchTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDaoSearchTest.kt @@ -4,6 +4,7 @@ import androidx.room3.Room import androidx.sqlite.driver.bundled.BundledSQLiteDriver import de.davis.keygo.core.item.data.local.datasource.ItemDatabase import de.davis.keygo.core.item.data.local.entity.ItemEntity +import de.davis.keygo.core.item.data.local.entity.LoginEntity import de.davis.keygo.core.item.data.local.entity.TagEntity import de.davis.keygo.core.item.data.local.entity.Timestamp import de.davis.keygo.core.item.data.local.entity.VaultEntity @@ -58,6 +59,7 @@ internal class ItemDaoSearchTest { name: String, note: String? = null, tags: Set = emptySet(), + username: String? = null, ): ItemId { val id = newItemId() itemDao.upsert( @@ -77,6 +79,7 @@ internal class ItemDaoSearchTest { id, tags.map { TagEntity(value = it, normalized = it.lowercase()) }.toSet(), ) + db.loginDao().upsert(LoginEntity(id = id, username = username)) return id } @@ -105,12 +108,51 @@ internal class ItemDaoSearchTest { } @Test - fun `searchItem does not return items with no name note or tag match`() = runTest { - insertItem(name = "Email", note = "personal", tags = setOf("Mail")) + fun `searchItem does not return items with no name note username or tag match`() = runTest { + insertItem(name = "Email", note = "personal", tags = setOf("Mail"), username = "me@x.io") assertTrue(itemDao.searchItem(query = "Bank", normalizedQuery = "bank").first().isEmpty()) } + @Test + fun `searchItem matches an item by username only`() = runTest { + val id = insertItem(name = "Chase", username = "treasurer@example.com") + + val r = itemDao.searchItem(query = "treasurer", normalizedQuery = "treasurer") + .first() + .single() + + assertEquals(id, r.id) + assertTrue(r.matchedUsername) + assertFalse(r.matchedName) + assertFalse(r.matchedNote) + assertFalse(r.matchedTag) + } + + /** + * The login join is one-to-one on a shared primary key, so a username match must not duplicate + * the item row it already matched by name. + */ + @Test + fun `searchItem returns a single row when name and username both match`() = runTest { + insertItem(name = "Bank", username = "bank-admin") + + val r = itemDao.searchItem(query = "bank", normalizedQuery = "bank").first().single() + + assertTrue(r.matchedName) + assertTrue(r.matchedUsername) + } + + @Test + fun `searchItem leaves matchedUsername false for an item without a username`() = runTest { + insertItem(name = "Bank of Earth", username = null) + + val r = itemDao.searchItem(query = "Bank", normalizedQuery = "bank").first().single() + + assertTrue(r.matchedName) + assertFalse(r.matchedUsername) + } + @Test fun `searchItem tag match still passes an explicit itemType filter`() = runTest { insertItem(name = "Chase", tags = setOf("Bank")) diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt index ef5897c42..5ff0504cf 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/mapper/ItemMapperTest.kt @@ -113,6 +113,7 @@ class ItemMapperTest { name = "Search result", itemType = VaultItemType.Login, matchedName = true, + matchedUsername = true, matchedNote = false, matchedTag = true, pinned = false, @@ -125,6 +126,7 @@ class ItemMapperTest { assertEquals(VaultItemType.Login, result.itemType) assertTrue(result.matchedName) assertFalse(result.matchedNote) + assertTrue(result.matchedUsername) assertTrue(result.matchedTag) assertFalse(result.pinned) } diff --git a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeItemRepository.kt b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeItemRepository.kt index 6652bbccc..4158f9861 100644 --- a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeItemRepository.kt +++ b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeItemRepository.kt @@ -103,8 +103,10 @@ class FakeItemRepository( .mapNotNull { item -> val matchedName = item.name.contains(query, ignoreCase = true) val matchedNote = item.note?.contains(query, ignoreCase = true) == true + val matchedUsername = item.username?.contains(query, ignoreCase = true) == true val matchedTag = item.tags.any { it.display.contains(query, ignoreCase = true) } - if (!matchedName && !matchedNote && !matchedTag) return@mapNotNull null + if (!matchedName && !matchedNote && !matchedUsername && !matchedTag) + return@mapNotNull null LiteItemSearchResult( id = item.id, @@ -113,6 +115,7 @@ class FakeItemRepository( pinned = item.pinned, matchedName = matchedName, matchedNote = matchedNote, + matchedUsername = matchedUsername, matchedTag = matchedTag, ) } diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 42fd502cf..cd5acd76b 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -4,9 +4,6 @@ Create new Item - Matched name - Matched note - Matched name and note No matches found Copy to clipboard diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCase.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCase.kt new file mode 100644 index 000000000..60ff7cba1 --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCase.kt @@ -0,0 +1,40 @@ +package de.davis.keygo.feature.list_screen.domain.usecase + +import de.davis.keygo.core.item.domain.model.lite.LiteItemSearchResult +import de.davis.keygo.core.util.domain.usecase.SortUseCase +import org.koin.core.annotation.Single + +@Single +class RankSearchResultsUseCase( + private val sortUseCase: SortUseCase, +) { + + operator fun invoke( + query: String, + results: List, + ): List { + val alphabetical = sortUseCase(results) { it.name } + if (query.isBlank()) return alphabetical + + // sortedBy is stable, so the alphabetical order survives within each rank. + return alphabetical.sortedBy { rankOf(query, it) } + } + + private fun rankOf(query: String, result: LiteItemSearchResult): Int = when { + result.name.equals(query, ignoreCase = true) -> EXACT_NAME + result.matchedName && result.name.startsWith(query, ignoreCase = true) -> NAME_PREFIX + result.matchedName -> NAME + result.matchedUsername -> USERNAME + result.matchedTag -> TAG + else -> NOTE + } + + private companion object { + const val EXACT_NAME = 0 + const val NAME_PREFIX = 1 + const val NAME = 2 + const val USERNAME = 3 + const val TAG = 4 + const val NOTE = 5 + } +} diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt index 6285e4dc8..d4504a4b9 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt @@ -16,6 +16,7 @@ import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.util.combine import de.davis.keygo.feature.list_screen.domain.model.FilterState import de.davis.keygo.feature.list_screen.domain.usecase.FilterUseCase +import de.davis.keygo.feature.list_screen.domain.usecase.RankSearchResultsUseCase import de.davis.keygo.feature.list_screen.presentation.mapper.toAvailableFilterOptions import de.davis.keygo.feature.list_screen.presentation.mapper.toBottomSheetState import de.davis.keygo.feature.list_screen.presentation.model.Event @@ -23,6 +24,7 @@ import de.davis.keygo.feature.list_screen.presentation.model.FilterAction import de.davis.keygo.feature.list_screen.presentation.model.FilterBottomSheetState import de.davis.keygo.feature.list_screen.presentation.model.ItemSelection import de.davis.keygo.feature.list_screen.presentation.model.ListItemState +import de.davis.keygo.feature.list_screen.presentation.model.SearchState import de.davis.keygo.feature.vault.domain.usecase.ObserveVaultsAndSelectionUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -60,6 +62,7 @@ internal class ItemListViewModel( @InjectedParam private val restrictedItemType: VaultItemType?, private val itemRepository: ItemRepository, private val filterUseCase: FilterUseCase, + private val rankSearchResults: RankSearchResultsUseCase, observeAllTags: ObserveAllTagsSortedUseCase, observeVaultsAndSelection: ObserveVaultsAndSelectionUseCase, loginRepository: LoginRepository, @@ -107,31 +110,33 @@ internal class ItemListViewModel( private val _isDeleteConfirmationVisible = MutableStateFlow(false) @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) - private val searchResults = snapshotFlow { searchTextFieldState.text } + private val searchState = snapshotFlow { searchTextFieldState.text.toString() } .debounce(SEARCH_DEBOUNCE) - .flatMapLatest { - queryToItems(it.toString(), forceSearchAllVaults = true) + .distinctUntilChanged() + .flatMapLatest { query -> + itemRepository.searchVaultItem(query, restrictedItemType) + .map { SearchState(query, rankSearchResults(query, it)) } } .distinctUntilChanged() .flowOn(Dispatchers.Default) // Nobody has searched yet, and combine withholds its first emission until every input has // emitted: without this the list screen's first render would wait on a full cross-vault // search for the empty query. - .onStart { emit(emptyList()) } + .onStart { emit(SearchState()) } val listItemState = combine( vaultsAndSelection, filteredItems, - searchResults, + searchState, selection, submittedSearchQuery, highlightedId, _isVaultFlowVisible, _isDeleteConfirmationVisible, - ) { vaultsAndSel, items, searchResults, selection, submittedSearchQuery, highlightedId, isVaultFlowVisible, isDeleteConfirmationVisible -> + ) { vaultsAndSel, items, searchState, selection, submittedSearchQuery, highlightedId, isVaultFlowVisible, isDeleteConfirmationVisible -> ListItemState( items = items, - searchResults = searchResults, + searchState = searchState, hasSearchQuery = submittedSearchQuery.isNotBlank(), selection = selection, highlightedId = highlightedId, @@ -209,12 +214,9 @@ internal class ItemListViewModel( private fun Set.toggle(element: T): Set = if (element in this) this - element else this + element - private fun queryToItems( - query: String, - forceSearchAllVaults: Boolean = false - ): Flow> = - (if (!forceSearchAllVaults && query.isBlank()) vaultSpecificItems - else itemRepository.searchVaultItem(query, restrictedItemType)) + private fun queryToItems(query: String): Flow> = + if (query.isBlank()) vaultSpecificItems + else itemRepository.searchVaultItem(query, restrictedItemType) fun onSubmitQuery() { submittedSearchQuery.update { searchTextFieldState.text.toString() } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt index c6d6f70a0..b83581ae8 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt @@ -53,6 +53,7 @@ import de.davis.keygo.feature.list_screen.presentation.model.FilterAction import de.davis.keygo.feature.list_screen.presentation.model.FilterBottomSheetState import de.davis.keygo.feature.list_screen.presentation.model.ItemSectionState import de.davis.keygo.feature.list_screen.presentation.model.ListItemState +import de.davis.keygo.feature.list_screen.presentation.model.SearchState import de.davis.keygo.feature.vault.presentation.VaultFlow import kotlinx.coroutines.launch @@ -144,17 +145,13 @@ internal fun ItemListContent( val searchResultContent: @Composable ColumnScope.() -> Unit = { val scope = rememberCoroutineScope() SearchResult( - searchResult = uiState.searchResults, - idOf = { it.id }, - nameOf = { it.name }, - matchedInName = { true }, - matchedInNote = { false }, - onClick = { item -> + searchState = uiState.searchState, + onResultClick = { itemId -> scope.launch { searchBarState.animateToCollapsed() } // Clicking a search result should not select the item when currently // other items are selected. - onItemClick(item.id, true) + onItemClick(itemId, true) }, modifier = Modifier.padding(8.dp) ) @@ -267,6 +264,7 @@ private fun ItemListContentPreview() { itemType = VaultItemType.Login, pinned = false, matchedName = true, + matchedUsername = false, matchedNote = false, matchedTag = false, ) @@ -275,7 +273,10 @@ private fun ItemListContentPreview() { val uiState = remember { ListItemState( items = listOf(sampleItem), - searchResults = listOf(sampleItem), + searchState = SearchState( + results = listOf(sampleItem), + query = "Sam" + ), hasSearchQuery = false, highlightedId = null, ) diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/SearchResult.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/SearchResult.kt index 14ee76748..e82a79173 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/SearchResult.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/SearchResult.kt @@ -1,133 +1,275 @@ package de.davis.keygo.feature.list_screen.presentation.components import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.CardColors -import androidx.compose.material3.CardDefaults +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.SearchOff +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedListItem import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import de.davis.keygo.core.ui.R -import de.davis.keygo.core.ui.components.ItemVerticalPadding -import de.davis.keygo.core.ui.components.VaultItem +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.model.lite.LiteItemSearchResult +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.core.item.generated.presentation.presentation +import de.davis.keygo.feature.list_screen.R +import de.davis.keygo.feature.list_screen.presentation.model.SearchMatchField +import de.davis.keygo.feature.list_screen.presentation.model.SearchState +import de.davis.keygo.feature.list_screen.presentation.model.matchedFields @Composable -internal fun SearchResult( - searchResult: List, - idOf: (I) -> Any, - nameOf: (I) -> String, - matchedInName: (I) -> Boolean, - matchedInNote: (I) -> Boolean, - onClick: (I) -> Unit, +internal fun SearchResult( + searchState: SearchState, + onResultClick: (ItemId) -> Unit, modifier: Modifier = Modifier, - cardColors: CardColors = CardDefaults.cardColors() ) { - val isEmpty = remember(searchResult) { searchResult.isEmpty() } - Box(modifier = modifier) { - AnimatedContent(isEmpty) { - when (it) { - true -> { - EmptySearchResult() - } - - false -> { - SearchResultContent( - onClick = onClick, - idOf = idOf, - nameOf = nameOf, - matchedInName = matchedInName, - matchedInNote = matchedInNote, - searchResult = searchResult, - cardColors = cardColors - ) - } + AnimatedContent(targetState = searchState.results.isEmpty()) { isEmpty -> + when (isEmpty) { + true -> EmptySearchResult(query = searchState.query) + + false -> SearchResultContent( + searchState = searchState, + onResultClick = onResultClick, + ) } } } } @Composable -private fun EmptySearchResult() { - Box( +private fun SearchResultContent( + searchState: SearchState, + onResultClick: (ItemId) -> Unit, +) { + LazyColumn(verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap)) { + if (searchState.query.isNotBlank()) + item(key = ResultCountKey, contentType = ResultCountKey) { + Text( + text = pluralStringResource( + R.plurals.search_result_count, + searchState.results.size, + searchState.results.size, + ), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp, bottom = 4.dp), + ) + } + + itemsIndexed( + searchState.results, + key = { _, item -> item.id }, + contentType = { _, item -> item.itemType }, + ) { index, result -> + SearchResultRow( + index = index, + count = searchState.results.size, + result = result, + query = searchState.query, + onClick = { onResultClick(result.id) }, + ) + } + } +} + +@Composable +private fun SearchResultRow( + index: Int, + count: Int, + result: LiteItemSearchResult, + query: String, + onClick: () -> Unit, +) { + val (typeLabel, typeIcon) = result.itemType.presentation + val matchedFields = result.matchedFields() + + SegmentedListItem( + onClick = onClick, + shapes = if (count == 1) ListItemDefaults.shapes(MaterialTheme.shapes.large) + else ListItemDefaults.segmentedShapes(index, count), + colors = ListItemDefaults.segmentedColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ), + supportingContent = { + if (query.isBlank() || matchedFields.isEmpty()) Text(text = typeLabel) + else MatchedFields(fields = matchedFields) + }, + leadingContent = { ItemTypeBadge(icon = typeIcon, contentDescription = typeLabel) }, + ) { + Text( + text = highlightMatch(result.name, query), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun MatchedFields(fields: List) { + val labels = fields.map { it.label } + val reason = stringResource(R.string.search_match_reason, labels.joinToString()) + + FlowRow( modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - contentAlignment = Alignment.Center + .padding(top = 4.dp) + .semantics(mergeDescendants = true) { contentDescription = reason }, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Text(text = stringResource(R.string.match_not_found)) + fields.forEachIndexed { index, field -> + MatchedFieldChip(icon = field.icon, label = labels[index]) + } } } @Composable -private fun SearchResultContent( - searchResult: List, - idOf: (I) -> Any, - nameOf: (I) -> String, - matchedInName: (I) -> Boolean, - matchedInNote: (I) -> Boolean, - onClick: (I) -> Unit, - cardColors: CardColors -) { - LazyColumn( - verticalArrangement = Arrangement.spacedBy(ItemVerticalPadding) +private fun MatchedFieldChip(icon: ImageVector, label: String) { + Surface( + shape = MaterialTheme.shapes.extraSmall, + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, ) { - items(searchResult, key = { idOf(it) }) { item -> - VaultItem( - headlineContent = { - Text(text = nameOf(item)) - }, - supportingContent = { - when { - matchedInName(item) && matchedInNote(item) -> { - Text(text = stringResource(R.string.match_name_and_note)) - } - - matchedInName(item) -> { - Text(text = stringResource(R.string.match_name)) - } - - matchedInNote(item) -> { - Text(text = stringResource(R.string.match_note)) - } - } - }, - modifier = Modifier.clickable { - onClick(item) - }, - cardColors = cardColors + Row( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(12.dp), ) + Text(text = label, style = MaterialTheme.typography.labelSmall) } } } +@Composable +private fun ItemTypeBadge(icon: ImageVector, contentDescription: String) { + Surface( + modifier = Modifier.size(40.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun highlightMatch(name: String, query: String): AnnotatedString { + val style = SpanStyle( + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + + return remember(name, query, style) { + buildAnnotatedString { + append(name) + if (query.isBlank()) return@buildAnnotatedString + + var start = name.indexOf(query, ignoreCase = true) + while (start >= 0) { + val end = start + query.length + addStyle(style, start, end) + start = name.indexOf(query, startIndex = end, ignoreCase = true) + } + } + } +} + +@Composable +private fun EmptySearchResult(query: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 40.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Default.SearchOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + Text( + text = stringResource(R.string.search_no_matches), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = if (query.isBlank()) stringResource(R.string.search_nothing_to_search) + else stringResource(R.string.search_no_matches_for, query), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} + +private const val ResultCountKey = "search-result-count" + @Preview @Composable private fun SearchResultPreview() { - val items = listOf("Example Item 1", "Example Item 2") + val results = remember { + listOf( + searchResult("GitHub", matchedName = true), + searchResult("GitLab", matchedName = true, matchedTag = true), + searchResult("Digital Ocean", matchedUsername = true), + searchResult("Recovery codes", matchedNote = true), + searchResult( + "Travel card", + itemType = VaultItemType.CreditCard, + matchedNote = true, + matchedTag = true, + ), + ) + } + MaterialTheme { Surface(modifier = Modifier.fillMaxSize()) { SearchResult( - searchResult = items, - idOf = { it }, - nameOf = { it }, - matchedInName = { true }, - matchedInNote = { it == items.first() }, - onClick = {} + searchState = SearchState(query = "git", results = results), + onResultClick = {}, ) } } @@ -139,13 +281,27 @@ private fun NoMatchPreview() { MaterialTheme { Surface(modifier = Modifier.fillMaxSize()) { SearchResult( - searchResult = listOf(), - idOf = { it }, - nameOf = { "" }, - matchedInName = { true }, - matchedInNote = { false }, - onClick = {} + searchState = SearchState(query = "banana", results = emptyList()), + onResultClick = {}, ) } } -} \ No newline at end of file +} + +private fun searchResult( + name: String, + itemType: VaultItemType = VaultItemType.Login, + matchedName: Boolean = false, + matchedUsername: Boolean = false, + matchedNote: Boolean = false, + matchedTag: Boolean = false, +) = LiteItemSearchResult( + id = newItemId(), + name = name, + itemType = itemType, + pinned = false, + matchedName = matchedName, + matchedUsername = matchedUsername, + matchedNote = matchedNote, + matchedTag = matchedTag, +) diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/ListItemState.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/ListItemState.kt index 3d014ac3b..aeefd3044 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/ListItemState.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/ListItemState.kt @@ -9,7 +9,7 @@ import de.davis.keygo.core.item.domain.model.lite.LiteItem @Stable internal data class ListItemState( val items: List = emptyList(), - val searchResults: List = emptyList(), + val searchState: SearchState = SearchState(), val hasSearchQuery: Boolean = false, val selection: ItemSelection = ItemSelection(), val highlightedId: ItemId? = null, diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/SearchMatchField.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/SearchMatchField.kt new file mode 100644 index 000000000..1d2f80a9f --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/SearchMatchField.kt @@ -0,0 +1,44 @@ +package de.davis.keygo.feature.list_screen.presentation.model + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.filled.AlternateEmail +import androidx.compose.material.icons.filled.Sell +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import de.davis.keygo.core.item.domain.model.lite.LiteItemSearchResult +import de.davis.keygo.feature.list_screen.R + +internal enum class SearchMatchField { + Name, + Username, + Note, + Tag; + + val label: String + @Composable get() = stringResource( + when (this) { + Name -> R.string.search_match_name + Username -> R.string.search_match_username + Note -> R.string.search_match_note + Tag -> R.string.search_match_tag + } + ) + + val icon: ImageVector + get() = when (this) { + Name -> Icons.Default.TextFields + Username -> Icons.Default.AlternateEmail + Note -> Icons.AutoMirrored.Filled.Notes + Tag -> Icons.Default.Sell + } +} + +internal fun LiteItemSearchResult.matchedFields(): List = buildList { + if (matchedName) add(SearchMatchField.Name) + if (matchedUsername) add(SearchMatchField.Username) + if (matchedNote) add(SearchMatchField.Note) + if (matchedTag) add(SearchMatchField.Tag) +} diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/SearchState.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/SearchState.kt new file mode 100644 index 000000000..3a50518ba --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/SearchState.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.list_screen.presentation.model + +import androidx.compose.runtime.Stable +import de.davis.keygo.core.item.domain.model.lite.LiteItemSearchResult + +@Stable +internal data class SearchState( + val query: String = "", + val results: List = emptyList(), +) \ No newline at end of file diff --git a/feature/list_screen/src/main/res/values/strings.xml b/feature/list_screen/src/main/res/values/strings.xml index a9c18ef88..17e156d77 100644 --- a/feature/list_screen/src/main/res/values/strings.xml +++ b/feature/list_screen/src/main/res/values/strings.xml @@ -2,6 +2,21 @@ Search your Vault + Name + Username + Note + Tag + Matched in %1$s + + + %1$d result + %1$d results + + + No matches + Nothing in your vault matches \"%1$s\" + There is nothing in this vault to search yet + %d selected Clear selection Select all diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCaseTest.kt new file mode 100644 index 000000000..851fd608f --- /dev/null +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/RankSearchResultsUseCaseTest.kt @@ -0,0 +1,116 @@ +package de.davis.keygo.feature.list_screen.domain.usecase + +import de.davis.keygo.core.item.domain.model.lite.LiteItemSearchResult +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.core.util.domain.usecase.SortUseCase +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals + +class RankSearchResultsUseCaseTest { + + private val useCase = RankSearchResultsUseCase(SortUseCase()) + + private fun result( + name: String, + matchedName: Boolean = false, + matchedUsername: Boolean = false, + matchedNote: Boolean = false, + matchedTag: Boolean = false, + ) = LiteItemSearchResult( + id = UUID.nameUUIDFromBytes(name.toByteArray()), + name = name, + itemType = VaultItemType.Login, + pinned = false, + matchedName = matchedName, + matchedUsername = matchedUsername, + matchedNote = matchedNote, + matchedTag = matchedTag, + ) + + private fun rank(query: String, vararg results: LiteItemSearchResult) = + useCase(query, results.toList()).map { it.name } + + @Test + fun `name matches outrank tag matches and tag matches outrank note-only matches`() { + val ordered = rank( + "git", + result("Notes about git", matchedNote = true), + result("Tagged git", matchedTag = true), + result("My git remote", matchedName = true), + ) + + assertEquals(listOf("My git remote", "Tagged git", "Notes about git"), ordered) + } + + @Test + fun `a name match outranks a username match and a username match outranks a tag match`() { + val ordered = rank( + "git", + result("Alpha tagged", matchedTag = true), + result("Zulu account", matchedUsername = true), + result("Middle git", matchedName = true), + ) + + assertEquals(listOf("Middle git", "Zulu account", "Alpha tagged"), ordered) + } + + @Test + fun `a username match outranks a note-only match`() { + val ordered = rank( + "git", + result("Alpha note", matchedNote = true), + result("Zulu account", matchedUsername = true), + ) + + assertEquals(listOf("Zulu account", "Alpha note"), ordered) + } + + @Test + fun `an exact name beats a prefix and a prefix beats a match in the middle`() { + val ordered = rank( + "git", + result("Ancient git", matchedName = true), + result("GitHub", matchedName = true), + result("git", matchedName = true), + ) + + assertEquals(listOf("git", "GitHub", "Ancient git"), ordered) + } + + @Test + fun `results of the same rank stay alphabetical`() { + val ordered = rank( + "git", + result("Zeta git", matchedName = true), + result("Alpha git", matchedName = true), + result("Middle git", matchedName = true), + ) + + assertEquals(listOf("Alpha git", "Middle git", "Zeta git"), ordered) + } + + @Test + fun `an item matching several fields is ranked by its strongest field`() { + val ordered = rank( + "git", + result("Zeta", matchedTag = true), + result("Alpha note only", matchedNote = true), + result("Yankee git client", matchedName = true, matchedNote = true, matchedTag = true), + ) + + assertEquals(listOf("Yankee git client", "Zeta", "Alpha note only"), ordered) + } + + @Test + fun `a blank query falls back to plain alphabetical order`() { + val ordered = rank( + "", + result("Charlie", matchedName = true), + result("alpha", matchedName = true), + result("Bravo", matchedName = true), + ) + + assertEquals(listOf("alpha", "Bravo", "Charlie"), ordered) + } +} diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt index 62537f01a..dddc90447 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt @@ -15,6 +15,7 @@ import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.list_screen.domain.usecase.FilterUseCase +import de.davis.keygo.feature.list_screen.domain.usecase.RankSearchResultsUseCase import de.davis.keygo.feature.vault.domain.usecase.ObserveVaultsAndSelectionUseCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -60,6 +61,7 @@ class ItemListViewModelTest { restrictedItemType = null, itemRepository = itemRepository, filterUseCase = FilterUseCase(sortUseCase), + rankSearchResults = RankSearchResultsUseCase(sortUseCase), observeAllTags = ObserveAllTagsSortedUseCase(itemRepository, sortUseCase), observeVaultsAndSelection = ObserveVaultsAndSelectionUseCase( vaultRepository = vaultRepository,