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,