From 09cace4f9b357ce8e92c21905e2a74a1e16aa40e Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 29 Sep 2026 12:51:16 +0200 Subject: [PATCH 1/9] feat(list): show a clear-filters empty state when filters hide every item --- .../presentation/ItemListViewModel.kt | 6 ++-- .../components/ItemListContent.kt | 23 +++++++++++++-- .../presentation/model/ListItemState.kt | 1 + .../src/main/res/values/strings.xml | 3 ++ .../presentation/ItemListViewModelTest.kt | 29 +++++++++++++++++++ 5 files changed, 57 insertions(+), 5 deletions(-) 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 1879c3ea7..d5932c9a5 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 @@ -105,7 +105,8 @@ internal class ItemListViewModel( passwordScores, tagFilteredItemIds, ) { items, filter, scores, tagIds -> - filterUseCase(filter, items, scores, tagIds) + val filtered = filterUseCase(filter, items, scores, tagIds) + filtered to (filtered.isEmpty() && items.isNotEmpty()) }.distinctUntilChanged() private val selection = MutableStateFlow(ItemSelection()) @@ -136,9 +137,10 @@ internal class ItemListViewModel( _isVaultFlowVisible, _isDeleteConfirmationVisible, cardExpiryStatuses, - ) { vaultsAndSel, items, searchState, selection, submittedSearchQuery, isVaultFlowVisible, isDeleteConfirmationVisible, expiryStatuses -> + ) { vaultsAndSel, (items, isEmptyBecauseOfFilter), searchState, selection, submittedSearchQuery, isVaultFlowVisible, isDeleteConfirmationVisible, expiryStatuses -> ListItemState( items = items, + isEmptyBecauseOfFilter = isEmptyBecauseOfFilter, cardExpiryStatuses = expiryStatuses, searchState = searchState, hasSearchQuery = submittedSearchQuery.isNotBlank(), 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 571f01997..c5f8f87d6 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 @@ -3,6 +3,7 @@ package de.davis.keygo.feature.list_screen.presentation.components import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -22,6 +23,7 @@ import androidx.compose.material3.SearchBarScrollBehavior import androidx.compose.material3.SearchBarState import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -60,6 +62,7 @@ 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 +import de.davis.keygo.feature.list_screen.R as ListScreenR @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -191,8 +194,12 @@ internal fun ItemListContent( ) { val showCreateCard = !uiState.hasSearchQuery && notFoundStrategy is NoItemStrategy.ShowCreateNewItemCard - when (showCreateCard) { - true -> { + when { + uiState.isEmptyBecauseOfFilter -> NoFilterMatches( + onClearFilters = { onFilterAction(FilterAction.ClearFilters) }, + ) + + showCreateCard -> { val createTypes = remember(restrictedItemType) { restrictedItemType?.let { listOf(it) } ?: VaultItemType.entries @@ -216,7 +223,7 @@ internal fun ItemListContent( } } - false -> Text(text = stringResource(R.string.match_not_found)) + else -> Text(text = stringResource(R.string.match_not_found)) } } } @@ -342,3 +349,13 @@ private fun ItemListContentPreview() { } } } + +@Composable +private fun NoFilterMatches(onClearFilters: () -> Unit) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(text = stringResource(ListScreenR.string.no_filter_matches)) + TextButton(onClick = onClearFilters) { + Text(text = stringResource(ListScreenR.string.clear_filters)) + } + } +} 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 8ef5eafea..247db2e63 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 @@ -10,6 +10,7 @@ import de.davis.keygo.core.item.domain.model.lite.LiteItem @Stable internal data class ListItemState( val items: List = emptyList(), + val isEmptyBecauseOfFilter: Boolean = false, val cardExpiryStatuses: Map = emptyMap(), val searchState: SearchState = SearchState(), val hasSearchQuery: Boolean = false, diff --git a/feature/list_screen/src/main/res/values/strings.xml b/feature/list_screen/src/main/res/values/strings.xml index 17e156d77..51d05ed3f 100644 --- a/feature/list_screen/src/main/res/values/strings.xml +++ b/feature/list_screen/src/main/res/values/strings.xml @@ -49,4 +49,7 @@ Tags Only pinned Items + + No items match your filters + Clear filters 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 7da35ae85..2c73bed9c 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 @@ -21,6 +21,7 @@ import de.davis.keygo.feature.list_screen.domain.usecase.FilterUseCase import de.davis.keygo.feature.list_screen.domain.usecase.ObserveCardExpiryStatusesUseCase import de.davis.keygo.feature.list_screen.domain.usecase.RankSearchResultsUseCase import de.davis.keygo.feature.list_screen.presentation.model.Event +import de.davis.keygo.feature.list_screen.presentation.model.FilterAction import de.davis.keygo.feature.vault.domain.usecase.ObserveVaultsAndSelectionUseCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -426,6 +427,34 @@ class ItemListViewModelTest { assertEquals(Event.ItemSelected(opened.id), selected.await()) } + @Test + fun `deleting every item a filter matches leaves a list that is empty because of the filter`() = + runTest(dispatcher) { + val pinned = login("Pinned", pinned = true) + loginRepository.seed(pinned, login("Loose")) + + val vm = viewModel() + backgroundScope.launchCollect(vm) + vm.onFilterAction(FilterAction.ShowOnlyPinnedToggled) + vm.onItemLongClick(pinned.id) + vm.onDeleteSelectedRequest() + vm.onConfirmDeleteSelected() + advanceUntilIdle() + + assertTrue(vm.listItemState.value.items.isEmpty()) + assertTrue(vm.listItemState.value.isEmptyBecauseOfFilter) + } + + @Test + fun `an empty vault is not empty because of the filter`() = runTest(dispatcher) { + val vm = viewModel() + backgroundScope.launchCollect(vm) + vm.onFilterAction(FilterAction.ShowOnlyPinnedToggled) + advanceUntilIdle() + + assertFalse(vm.listItemState.value.isEmptyBecauseOfFilter) + } + @Test fun `an expired card carries its status into the list state`() = runTest(dispatcher) { val card = expiredCard() From cdfb944145b3d83f2476b3411009ea5568795461 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 29 Sep 2026 14:53:21 +0200 Subject: [PATCH 2/9] feat(facets): implement FacetSelections and FilterFacet for managing filter states --- .../domain/model/FacetSelections.kt | 35 ++++++++++++ .../list_screen/domain/model/FilterFacet.kt | 13 +++++ .../list_screen/domain/model/FilterResult.kt | 9 ++++ .../list_screen/domain/model/ItemFacets.kt | 14 +++++ .../domain/model/FacetSelectionsTest.kt | 54 +++++++++++++++++++ 5 files changed, 125 insertions(+) create mode 100644 feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FacetSelections.kt create mode 100644 feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterFacet.kt create mode 100644 feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterResult.kt create mode 100644 feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemFacets.kt create mode 100644 feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/model/FacetSelectionsTest.kt diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FacetSelections.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FacetSelections.kt new file mode 100644 index 000000000..31c29151b --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FacetSelections.kt @@ -0,0 +1,35 @@ +package de.davis.keygo.feature.list_screen.domain.model + +@JvmInline +value class FacetSelections private constructor( + private val byFacet: Map, Set>, +) { + + val facets: Set> get() = byFacet.keys + + val isEmpty: Boolean get() = byFacet.isEmpty() + + // Every write goes through with(), which stores a facet's values only under that facet. + @Suppress("UNCHECKED_CAST") + operator fun get(facet: FilterFacet): Set = + byFacet[facet].orEmpty() as Set + + // An empty set is dropped rather than stored, so a cleared facet compares equal to one that + // was never touched. + fun with(facet: FilterFacet, values: Set): FacetSelections = + FacetSelections(if (values.isEmpty()) byFacet - facet else byFacet + (facet to values)) + + fun toggle(facet: FilterFacet, value: T): FacetSelections { + val current = get(facet) + return with(facet, if (value in current) current - value else current + value) + } + + operator fun plus(other: FacetSelections): FacetSelections = + FacetSelections( + (facets + other.facets).associateWith { byFacet[it].orEmpty() + other.byFacet[it].orEmpty() }, + ) + + companion object { + val None = FacetSelections(emptyMap()) + } +} diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterFacet.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterFacet.kt new file mode 100644 index 000000000..20c2e267b --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterFacet.kt @@ -0,0 +1,13 @@ +package de.davis.keygo.feature.list_screen.domain.model + +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.core.item.generated.domain.model.VaultItemType + +sealed interface FilterFacet { + data object ItemTypes : FilterFacet + data object Tags : FilterFacet + data object PasswordScores : FilterFacet + + data object Pinned : FilterFacet +} diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterResult.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterResult.kt new file mode 100644 index 000000000..08aa9d431 --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterResult.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.feature.list_screen.domain.model + +import de.davis.keygo.core.item.domain.model.lite.LiteItem + +data class FilterResult( + val items: List, + val available: FacetSelections, + val isEmptyBecauseOfFilter: Boolean, +) diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemFacets.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemFacets.kt new file mode 100644 index 000000000..f45b6a26d --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemFacets.kt @@ -0,0 +1,14 @@ +package de.davis.keygo.feature.list_screen.domain.model + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.Tag + +data class ItemFacets( + val passwordScores: Map = emptyMap(), + val tagsByItem: Map> = emptyMap(), +) { + companion object { + val None = ItemFacets() + } +} diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/model/FacetSelectionsTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/model/FacetSelectionsTest.kt new file mode 100644 index 000000000..415a015e3 --- /dev/null +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/model/FacetSelectionsTest.kt @@ -0,0 +1,54 @@ +package de.davis.keygo.feature.list_screen.domain.model + +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class FacetSelectionsTest { + + @Test + fun `a facet nothing was selected in is empty`() { + assertTrue(FacetSelections.None[FilterFacet.Tags].isEmpty()) + } + + @Test + fun `toggling a value on and off again is the same as never touching it`() { + val toggled = FacetSelections.None + .toggle(FilterFacet.PasswordScores, PasswordScore.Weak) + .toggle(FilterFacet.PasswordScores, PasswordScore.Weak) + + assertEquals(FacetSelections.None, toggled) + assertTrue(toggled.isEmpty) + } + + @Test + fun `toggling one facet leaves the others alone`() { + val selections = FacetSelections.None + .toggle(FilterFacet.ItemTypes, VaultItemType.Login) + .toggle(FilterFacet.PasswordScores, PasswordScore.Weak) + + assertEquals(setOf(VaultItemType.Login), selections[FilterFacet.ItemTypes]) + assertEquals(setOf(PasswordScore.Weak), selections[FilterFacet.PasswordScores]) + } + + @Test + fun `plus unions the values of every facet`() { + val left = FacetSelections.None + .with(FilterFacet.PasswordScores, setOf(PasswordScore.Weak)) + .with(FilterFacet.Pinned, setOf(true)) + val right = FacetSelections.None + .with(FilterFacet.PasswordScores, setOf(PasswordScore.Strong)) + .with(FilterFacet.ItemTypes, setOf(VaultItemType.Login)) + + val union = left + right + + assertEquals( + setOf(PasswordScore.Weak, PasswordScore.Strong), + union[FilterFacet.PasswordScores], + ) + assertEquals(setOf(true), union[FilterFacet.Pinned]) + assertEquals(setOf(VaultItemType.Login), union[FilterFacet.ItemTypes]) + } +} From bbd7f1ca2ee236dba36003269529e89cf03387bb Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 29 Sep 2026 14:55:36 +0200 Subject: [PATCH 3/9] feat(filters): enhance filter actions with dynamic toggling and visibility management --- .../presentation/ItemListScreen.kt | 2 + .../presentation/ItemListViewModel.kt | 49 +++++++------------ .../components/ItemListContent.kt | 16 +++--- .../presentation/model/FilterAction.kt | 15 +++--- .../model/FilterBottomSheetState.kt | 1 + 5 files changed, 37 insertions(+), 46 deletions(-) diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListScreen.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListScreen.kt index 517a0f2a7..df3526c1d 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListScreen.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListScreen.kt @@ -120,6 +120,8 @@ fun ItemListScreen( onSubmitQuery = viewModel::onSubmitQuery, onClearQuery = viewModel::onClearQuery, onFilterAction = viewModel::onFilterAction, + onShowFilterSheet = viewModel::onShowFilterSheet, + onDismissFilterSheet = viewModel::onDismissFilterSheet, onItemClick = viewModel::onItemClick, onItemLongClick = viewModel::onItemLongClick, onClearSelection = viewModel::onClearSelection, 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 d5932c9a5..997e2154c 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 @@ -89,15 +89,11 @@ internal class ItemListViewModel( private val cardExpiryStatuses = observeCardExpiryStatuses().distinctUntilChanged() private val filterState = MutableStateFlow(FilterState.Default) + private val isFilterSheetVisible = MutableStateFlow(false) - @OptIn(ExperimentalCoroutinesApi::class) - private val tagFilteredItemIds: Flow?> = filterState - .map { it.selectedTags } - .distinctUntilChanged() - .flatMapLatest { tags -> - if (tags.isEmpty()) flowOf(null) - else itemRepository.observeItemIdsForTags(tags) - } + // Everything selected since the sheet opened. Those chips stay on screen until it closes, even + // once deselected and carried by no item, so the sheet never reshuffles under the user's finger. + private val retainedSelections = MutableStateFlow(FacetSelections.None) private val filteredItems = combine( itemSource, @@ -183,29 +179,25 @@ internal class ItemListViewModel( val searchTextFieldState = TextFieldState() fun onFilterAction(action: FilterAction) { - when (action) { - is FilterAction.SortDirectionChanged -> filterState.update { - it.copy(sortDirection = action.direction) - } - - is FilterAction.ItemTypeToggled -> filterState.update { - it.copy(selectedItemTypes = it.selectedItemTypes.toggle(action.itemType)) - } - - is FilterAction.TagToggled -> filterState.update { - it.copy(selectedTags = it.selectedTags.toggle(action.tag)) + val filter = filterState.updateAndGet { + when (action) { + is FilterAction.SortDirectionChanged -> it.copy(sortDirection = action.direction) + is FilterAction.Toggled<*> -> it.copy(selections = action.applyTo(it.selections)) + FilterAction.ClearFilters -> FilterState.Default } + } - is FilterAction.ScoreToggled -> filterState.update { - it.copy(selectedScores = it.selectedScores.toggle(action.passwordScore)) - } + if (isFilterSheetVisible.value) retainedSelections.update { it + filter.selections } + } - FilterAction.ShowOnlyPinnedToggled -> filterState.update { - it.copy(onlyPinned = !it.onlyPinned) - } + fun onShowFilterSheet() { + retainedSelections.update { filterState.value.selections } + isFilterSheetVisible.update { true } + } - is FilterAction.ClearFilters -> filterState.update { FilterState.Default } - } + fun onDismissFilterSheet() { + isFilterSheetVisible.update { false } + retainedSelections.update { FacetSelections.None } } fun onVaultSelectorClick() { @@ -216,9 +208,6 @@ internal class ItemListViewModel( _isVaultFlowVisible.update { false } } - private fun Set.toggle(element: T): Set = - if (element in this) this - element else this + element - private fun queryToItems(query: String): Flow> = if (query.isBlank()) vaultSpecificItems else itemRepository.searchVaultItem(query, restrictedItemType) 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 c5f8f87d6..ca0355357 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 @@ -26,12 +26,8 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -81,6 +77,8 @@ internal fun ItemListContent( onSubmitQuery: () -> Unit, onClearQuery: () -> Unit, onFilterAction: (FilterAction) -> Unit, + onShowFilterSheet: () -> Unit, + onDismissFilterSheet: () -> Unit, onItemClick: (ItemId, forceSkipSelection: Boolean) -> Unit, onItemLongClick: (ItemId) -> Unit, onClearSelection: () -> Unit, @@ -94,8 +92,6 @@ internal fun ItemListContent( scrollBehavior: SearchBarScrollBehavior, modifier: Modifier = Modifier ) { - var showFilterSheet by rememberSaveable { mutableStateOf(false) } - val searchInputField = @Composable { ListSearchTextField( searchTextFieldState = searchTextFieldState, @@ -103,17 +99,17 @@ internal fun ItemListContent( uiState = uiState, onSubmitQuery = onSubmitQuery, onClearQuery = onClearQuery, - onShowFilterClick = { showFilterSheet = true }, + onShowFilterClick = onShowFilterSheet, onVaultSelectorClick = onVaultSelectorClick, filterBottomSheetState = filterBottomSheetState, ) } - if (showFilterSheet) + if (filterBottomSheetState.isVisible) FilterBottomSheet( state = filterBottomSheetState, onAction = onFilterAction, - onDismiss = { showFilterSheet = false }, + onDismiss = onDismissFilterSheet, ) if (uiState.isVaultFlowVisible) @@ -334,6 +330,8 @@ private fun ItemListContentPreview() { onSubmitQuery = {}, onClearQuery = {}, onFilterAction = {}, + onShowFilterSheet = {}, + onDismissFilterSheet = {}, onItemClick = { _, _ -> }, onItemLongClick = {}, onClearSelection = {}, diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterAction.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterAction.kt index fa4572781..b1907da93 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterAction.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterAction.kt @@ -1,15 +1,16 @@ package de.davis.keygo.feature.list_screen.presentation.model -import de.davis.keygo.core.item.domain.model.PasswordScore -import de.davis.keygo.core.item.domain.model.Tag -import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.feature.list_screen.domain.model.FacetSelections +import de.davis.keygo.feature.list_screen.domain.model.FilterFacet import de.davis.keygo.feature.list_screen.domain.model.SortDirection internal sealed interface FilterAction { data class SortDirectionChanged(val direction: SortDirection) : FilterAction - data class ItemTypeToggled(val itemType: VaultItemType) : FilterAction - data class TagToggled(val tag: Tag) : FilterAction - data class ScoreToggled(val passwordScore: PasswordScore) : FilterAction - data object ShowOnlyPinnedToggled : FilterAction + + data class Toggled(val facet: FilterFacet, val value: T) : FilterAction { + fun applyTo(selections: FacetSelections): FacetSelections = + selections.toggle(facet, value) + } + data object ClearFilters : FilterAction } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt index 12e5aad33..64e893a6f 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt @@ -12,6 +12,7 @@ internal data class FilterBottomSheetState( val itemSection: ItemSectionState? = null, val passwordSection: PasswordSectionState? = null, val isDefault: Boolean = true, + val isVisible: Boolean = false, ) @Immutable From a4f05413135d36f741c2198ac5a2ef13c088e609 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 29 Sep 2026 14:58:28 +0200 Subject: [PATCH 4/9] feat(filters): refactor filter state management to use FacetSelections for improved flexibility --- .../list_screen/domain/model/FilterState.kt | 14 +- .../usecase/AvailableFacetValuesUseCase.kt | 33 +++ .../domain/usecase/FilterUseCase.kt | 48 ++--- .../usecase/ObserveFilterResultUseCase.kt | 40 ++++ .../presentation/ItemListViewModel.kt | 56 +++-- .../components/FilterBottomSheet.kt | 96 ++++----- .../components/ItemListContent.kt | 3 +- .../presentation/mapper/FilterMapper.kt | 89 ++++---- .../model/AvailableFilterOptions.kt | 32 ++- .../model/FilterBottomSheetState.kt | 3 +- .../domain/model/FilterStateTest.kt | 38 ++-- .../AvailableFacetValuesUseCaseTest.kt | 118 +++++++++++ .../domain/usecase/FilterUseCaseTest.kt | 194 +++++++++++------- .../usecase/ObserveFilterResultUseCaseTest.kt | 165 +++++++++++++++ .../presentation/ItemListViewModelTest.kt | 85 +++++++- .../presentation/mapper/FilterMapperTest.kt | 193 ++++++++++++----- 16 files changed, 871 insertions(+), 336 deletions(-) create mode 100644 feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCase.kt create mode 100644 feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCase.kt create mode 100644 feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCaseTest.kt create mode 100644 feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterState.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterState.kt index 11bd7e975..dcb8853d4 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterState.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterState.kt @@ -1,20 +1,18 @@ package de.davis.keygo.feature.list_screen.domain.model -import de.davis.keygo.core.item.domain.model.PasswordScore -import de.davis.keygo.core.item.domain.model.Tag -import de.davis.keygo.core.item.generated.domain.model.VaultItemType - data class FilterState( val sortDirection: SortDirection = SortDirection.Ascending, - val selectedScores: Set = emptySet(), - val selectedItemTypes: Set = emptySet(), - val selectedTags: Set = emptySet(), - val onlyPinned: Boolean = false + val selections: FacetSelections = FacetSelections.None, ) { val isDefault: Boolean get() = this == Default + operator fun get(facet: FilterFacet): Set = selections[facet] + + fun with(facet: FilterFacet, values: Set): FilterState = + copy(selections = selections.with(facet, values)) + companion object { val Default = FilterState() } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCase.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCase.kt new file mode 100644 index 000000000..ff5350c4e --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCase.kt @@ -0,0 +1,33 @@ +package de.davis.keygo.feature.list_screen.domain.usecase + +import de.davis.keygo.core.item.domain.model.lite.LiteItem +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.feature.list_screen.domain.model.FacetSelections +import de.davis.keygo.feature.list_screen.domain.model.FilterFacet +import de.davis.keygo.feature.list_screen.domain.model.ItemFacets +import org.koin.core.annotation.Single + +@Single +class AvailableFacetValuesUseCase { + + operator fun invoke(items: List, facets: ItemFacets): FacetSelections { + val itemTypes = items.mapTo(mutableSetOf()) { it.itemType } + + val tags = buildSet { + items.forEach { item -> facets.tagsByItem[item.id]?.let(::addAll) } + } + + // Only logins carry a password, so a score keyed to any other item type must not surface. + val loginIds = items.filter { it.itemType == VaultItemType.Login } + .mapTo(mutableSetOf()) { it.id } + val scores = facets.passwordScores.filterKeys { it in loginIds }.values.toSet() + + val pinned = if (items.any { it.pinned }) setOf(true) else emptySet() + + return FacetSelections.None + .with(FilterFacet.ItemTypes, itemTypes) + .with(FilterFacet.Tags, tags) + .with(FilterFacet.PasswordScores, scores) + .with(FilterFacet.Pinned, pinned) + } +} diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt index 0e244dbdd..3a1704594 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt @@ -1,11 +1,12 @@ package de.davis.keygo.feature.list_screen.domain.usecase -import de.davis.keygo.core.item.domain.alias.ItemId -import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.lite.LiteItem import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.util.domain.usecase.SortUseCase +import de.davis.keygo.feature.list_screen.domain.model.FacetSelections +import de.davis.keygo.feature.list_screen.domain.model.FilterFacet import de.davis.keygo.feature.list_screen.domain.model.FilterState +import de.davis.keygo.feature.list_screen.domain.model.ItemFacets import de.davis.keygo.feature.list_screen.domain.model.SortDirection import org.koin.core.annotation.Single @@ -17,14 +18,13 @@ class FilterUseCase( operator fun invoke( filterState: FilterState, items: List, - passwordScores: Map, - tagMatchingIds: Set? = null, + facets: ItemFacets, ): List { + val selections = filterState.selections val filtered = items.filter { item -> - matchesItemType(filterState, item) && - matchesScore(filterState, item, passwordScores) && - matchesPinnedState(filterState, item) && - matchesTags(tagMatchingIds, item) + selections.facets.all { facet -> + item.matches(facet, selections, facets) + } } val (pinned, unpinned) = filtered.partition { it.pinned } @@ -32,25 +32,19 @@ class FilterUseCase( sort(filterState.sortDirection, unpinned) } - private fun matchesPinnedState(filterState: FilterState, item: LiteItem): Boolean = - !filterState.onlyPinned || item.pinned - - private fun matchesTags(tagMatchingIds: Set?, item: LiteItem): Boolean = - tagMatchingIds == null || item.id in tagMatchingIds - - private fun matchesItemType(filterState: FilterState, item: LiteItem): Boolean = - filterState.selectedItemTypes.isEmpty() || item.itemType in filterState.selectedItemTypes - - private fun matchesScore( - filterState: FilterState, - item: LiteItem, - passwordScores: Map, - ): Boolean { - if (filterState.selectedScores.isEmpty()) return true - if (item.itemType != VaultItemType.Login) return true // non-password items - pass through - - val score = passwordScores[item.id] ?: return false - return score in filterState.selectedScores + private fun LiteItem.matches( + facet: FilterFacet<*>, + selections: FacetSelections, + facets: ItemFacets, + ): Boolean = when (facet) { + FilterFacet.ItemTypes -> itemType in selections[FilterFacet.ItemTypes] + FilterFacet.Tags -> facets.tagsByItem[id].orEmpty() + .any { it in selections[FilterFacet.Tags] } + + FilterFacet.Pinned -> pinned in selections[FilterFacet.Pinned] + // Only logins carry a password, so every other item passes through. + FilterFacet.PasswordScores -> itemType != VaultItemType.Login || + facets.passwordScores[id]?.let { it in selections[FilterFacet.PasswordScores] } == true } private fun sort(direction: SortDirection, items: List): List = diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCase.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCase.kt new file mode 100644 index 000000000..9fc6d405e --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCase.kt @@ -0,0 +1,40 @@ +package de.davis.keygo.feature.list_screen.domain.usecase + +import de.davis.keygo.core.item.domain.model.lite.LiteItem +import de.davis.keygo.core.item.domain.repository.ItemRepository +import de.davis.keygo.core.item.domain.repository.LoginRepository +import de.davis.keygo.feature.list_screen.domain.model.FilterResult +import de.davis.keygo.feature.list_screen.domain.model.FilterState +import de.davis.keygo.feature.list_screen.domain.model.ItemFacets +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import org.koin.core.annotation.Single + +@Single +class ObserveFilterResultUseCase( + private val itemRepository: ItemRepository, + private val loginRepository: LoginRepository, + private val filterUseCase: FilterUseCase, + private val availableFacetValues: AvailableFacetValuesUseCase, +) { + + operator fun invoke( + items: Flow>, + filterState: Flow, + ): Flow> { + val facets = combine( + loginRepository.observePasswordScores(), + itemRepository.observeTagsByItem(), + ::ItemFacets, + ) + return combine(items, filterState, facets) { items, filter, facets -> + val filtered = filterUseCase(filter, items, facets) + FilterResult( + items = filtered, + available = availableFacetValues(items, facets), + isEmptyBecauseOfFilter = filtered.isEmpty() && items.isNotEmpty(), + ) + }.distinctUntilChanged() + } +} 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 997e2154c..6940e64b8 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 @@ -10,13 +10,13 @@ import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.getIdOrNull import de.davis.keygo.core.item.domain.model.lite.LiteItem import de.davis.keygo.core.item.domain.repository.ItemRepository -import de.davis.keygo.core.item.domain.repository.LoginRepository import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase 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.FacetSelections 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.ObserveCardExpiryStatusesUseCase +import de.davis.keygo.feature.list_screen.domain.usecase.ObserveFilterResultUseCase 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 @@ -38,14 +38,15 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.launch import org.koin.core.annotation.InjectedParam import org.koin.core.annotation.KoinViewModel @@ -62,12 +63,11 @@ internal class ItemListViewModel( @InjectedParam private val enableSelection: Boolean, @InjectedParam private val restrictedItemType: VaultItemType?, private val itemRepository: ItemRepository, - private val filterUseCase: FilterUseCase, private val rankSearchResults: RankSearchResultsUseCase, observeAllTags: ObserveAllTagsSortedUseCase, observeVaultsAndSelection: ObserveVaultsAndSelectionUseCase, + observeFilterResult: ObserveFilterResultUseCase, observeCardExpiryStatuses: ObserveCardExpiryStatusesUseCase, - loginRepository: LoginRepository, ) : ViewModel() { private val vaultsAndSelection = observeVaultsAndSelection() @@ -84,8 +84,6 @@ internal class ItemListViewModel( .flatMapLatest(::queryToItems) .distinctUntilChanged() - private val passwordScores = loginRepository.observePasswordScores() - private val cardExpiryStatuses = observeCardExpiryStatuses().distinctUntilChanged() private val filterState = MutableStateFlow(FilterState.Default) @@ -95,15 +93,10 @@ internal class ItemListViewModel( // once deselected and carried by no item, so the sheet never reshuffles under the user's finger. private val retainedSelections = MutableStateFlow(FacetSelections.None) - private val filteredItems = combine( - itemSource, - filterState, - passwordScores, - tagFilteredItemIds, - ) { items, filter, scores, tagIds -> - val filtered = filterUseCase(filter, items, scores, tagIds) - filtered to (filtered.isEmpty() && items.isNotEmpty()) - }.distinctUntilChanged() + // Shared so the list and the filter sheet read off one upstream subscription instead of each + // recomputing the filter pipeline independently. + private val filterResult = observeFilterResult(itemSource, filterState) + .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), replay = 1) private val selection = MutableStateFlow(ItemSelection()) private val _isVaultFlowVisible = MutableStateFlow(false) @@ -126,17 +119,17 @@ internal class ItemListViewModel( val listItemState = combine( vaultsAndSelection, - filteredItems, + filterResult, searchState, selection, submittedSearchQuery, _isVaultFlowVisible, _isDeleteConfirmationVisible, cardExpiryStatuses, - ) { vaultsAndSel, (items, isEmptyBecauseOfFilter), searchState, selection, submittedSearchQuery, isVaultFlowVisible, isDeleteConfirmationVisible, expiryStatuses -> + ) { vaultsAndSel, filterResult, searchState, selection, submittedSearchQuery, isVaultFlowVisible, isDeleteConfirmationVisible, expiryStatuses -> ListItemState( - items = items, - isEmptyBecauseOfFilter = isEmptyBecauseOfFilter, + items = filterResult.items, + isEmptyBecauseOfFilter = filterResult.isEmptyBecauseOfFilter, cardExpiryStatuses = expiryStatuses, searchState = searchState, hasSearchQuery = submittedSearchQuery.isNotBlank(), @@ -153,20 +146,19 @@ internal class ItemListViewModel( initialValue = ListItemState(), ) - private val availableFilterOptions = combine( - itemSource, - passwordScores, - itemRepository.observeTagsByItem(), - observeAllTags(), - ) { items, scores, tagsByItem, allTags -> - items.toAvailableFilterOptions(scores, tagsByItem, allTags) - }.distinctUntilChanged() - val filterBottomSheetState = combine( filterState, - availableFilterOptions, - ) { filter, available -> - filter.toBottomSheetState(available, restrictedItemType) + filterResult.map { it.available }.distinctUntilChanged(), + observeAllTags(), + retainedSelections, + isFilterSheetVisible, + ) { filter, available, allTags, retained, isVisible -> + filter.toBottomSheetState( + available.toAvailableFilterOptions(allTags), + restrictedItemType, + retained, + isVisible, + ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt index 5498f141e..62c2a94ad 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt @@ -52,6 +52,7 @@ import de.davis.keygo.core.ui.components.KeyGoCardProperties import de.davis.keygo.core.ui.components.KeyGoSwitch import de.davis.keygo.core.ui.theme.KeyGoTheme import de.davis.keygo.feature.list_screen.R +import de.davis.keygo.feature.list_screen.domain.model.FilterFacet import de.davis.keygo.feature.list_screen.domain.model.SortDirection import de.davis.keygo.feature.list_screen.presentation.model.FilterAction import de.davis.keygo.feature.list_screen.presentation.model.FilterBottomSheetState @@ -134,7 +135,7 @@ private fun FilterBottomSheetContent( item(key = "passwords") { PasswordSection( state = state.passwordSection, - onScoreToggled = { onAction(FilterAction.ScoreToggled(it)) }, + onAction = onAction, modifier = Modifier.animateItem(), ) } @@ -158,11 +159,13 @@ private fun ItemSection( title = stringResource(R.string.item), ) - if (state.showPinnedSwitch) { + if (state.onlyPinned != null) { OutlinedCard { KeyGoSwitch( - checked = state.onlyPinnedChecked, - onCheckedChange = { onAction(FilterAction.ShowOnlyPinnedToggled) }, + checked = state.onlyPinned.selected, + onCheckedChange = { + onAction(FilterAction.Toggled(FilterFacet.Pinned, state.onlyPinned.value)) + }, shapes = ListItemDefaults.shapes( shape = CardDefaults.outlinedShape, pressedShape = CardDefaults.outlinedShape, @@ -184,23 +187,13 @@ private fun ItemSection( }, properties = KeyGoCardProperties.outlined(), ) { - FlowRow(horizontalArrangement = DefaultHorizontalArrangement) { - state.itemTypeChips.forEach { chip -> - FilterChip( - selected = chip.selected, - onClick = { onAction(FilterAction.ItemTypeToggled(chip.value)) }, - label = { - Text(text = chip.value.presentation.first) - }, - leadingIcon = { - Icon( - imageVector = chip.value.presentation.second, - contentDescription = null, - ) - }, - ) - } - } + FacetChips( + facet = FilterFacet.ItemTypes, + chips = state.itemTypeChips, + onAction = onAction, + label = { it.presentation.first }, + icon = { it.presentation.second }, + ) } } @@ -211,17 +204,12 @@ private fun ItemSection( }, properties = KeyGoCardProperties.outlined(), ) { - FlowRow(horizontalArrangement = DefaultHorizontalArrangement) { - state.tagChips.forEach { chip -> - FilterChip( - selected = chip.selected, - onClick = { onAction(FilterAction.TagToggled(chip.value)) }, - label = { - Text(text = chip.value.display) - }, - ) - } - } + FacetChips( + facet = FilterFacet.Tags, + chips = state.tagChips, + onAction = onAction, + label = { it.display }, + ) } } } @@ -274,7 +262,7 @@ private fun SortSection( @Composable private fun PasswordSection( state: PasswordSectionState, - onScoreToggled: (PasswordScore) -> Unit, + onAction: (FilterAction) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -291,17 +279,34 @@ private fun PasswordSection( Text(text = stringResource(R.string.password_strength)) }, ) { - FlowRow(horizontalArrangement = DefaultHorizontalArrangement) { - state.passwordScoreChips.forEach { chip -> - FilterChip( - selected = chip.selected, - onClick = { onScoreToggled(chip.value) }, - label = { - Text(text = chip.value.label()) - }, - ) - } - } + FacetChips( + facet = FilterFacet.PasswordScores, + chips = state.passwordScoreChips, + onAction = onAction, + label = { it.label() }, + ) + } + } +} + +@Composable +private fun FacetChips( + facet: FilterFacet, + chips: List>, + onAction: (FilterAction) -> Unit, + label: @Composable (T) -> String, + icon: (@Composable (T) -> ImageVector)? = null, +) { + FlowRow(horizontalArrangement = DefaultHorizontalArrangement) { + chips.forEach { chip -> + FilterChip( + selected = chip.selected, + onClick = { onAction(FilterAction.Toggled(facet, chip.value)) }, + label = { Text(text = label(chip.value)) }, + leadingIcon = icon?.let { + { Icon(imageVector = it(chip.value), contentDescription = null) } + }, + ) } } } @@ -362,8 +367,7 @@ private fun FilterBottomSheetContentPreview() { state = FilterBottomSheetState( sortDirection = SortDirection.Ascending, itemSection = ItemSectionState( - showPinnedSwitch = true, - onlyPinnedChecked = true, + onlyPinned = FilterChipState(value = true, selected = true), itemTypeChips = VaultItemType.entries.map { type -> FilterChipState(value = type, selected = false) }, 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 ca0355357..a99261833 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 @@ -304,8 +304,7 @@ private fun ItemListContentPreview() { FilterBottomSheetState( sortDirection = SortDirection.Ascending, itemSection = ItemSectionState( - showPinnedSwitch = false, - onlyPinnedChecked = false, + onlyPinned = null, itemTypeChips = emptyList(), tagChips = emptyList() ), diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt index 846836467..66b6b9515 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt @@ -1,79 +1,62 @@ package de.davis.keygo.feature.list_screen.presentation.mapper -import androidx.compose.ui.util.fastMapTo -import de.davis.keygo.core.item.domain.alias.ItemId -import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag -import de.davis.keygo.core.item.domain.model.lite.LiteItem import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.feature.list_screen.domain.model.FacetSelections +import de.davis.keygo.feature.list_screen.domain.model.FilterFacet import de.davis.keygo.feature.list_screen.domain.model.FilterState import de.davis.keygo.feature.list_screen.presentation.model.AvailableFilterOptions +import de.davis.keygo.feature.list_screen.presentation.model.FacetOptions import de.davis.keygo.feature.list_screen.presentation.model.FilterBottomSheetState -import de.davis.keygo.feature.list_screen.presentation.model.FilterChipState import de.davis.keygo.feature.list_screen.presentation.model.ItemSectionState import de.davis.keygo.feature.list_screen.presentation.model.PasswordSectionState internal fun FilterState.toBottomSheetState( available: AvailableFilterOptions, restrictedItemType: VaultItemType?, + retained: FacetSelections = FacetSelections.None, + isVisible: Boolean = false, ): FilterBottomSheetState { - val showItemTypeChips = restrictedItemType == null && available.itemTypes.size > 1 - val showItemSection = - showItemTypeChips || available.tags.isNotEmpty() || available.hasPinnedItems - - val effectiveItemTypes = restrictedItemType?.let { setOf(it) } ?: selectedItemTypes - val showLoginSection = available.hasPasswordItems && + val itemTypeChips = available.itemTypes.chips(selections, retained) + // A single type offers no choice, unless the user has already picked it. + val showItemTypeChips = restrictedItemType == null && + (itemTypeChips.size > 1 || (selections + retained)[FilterFacet.ItemTypes].isNotEmpty()) + val onlyPinned = available.pinned.chips(selections, retained).singleOrNull() + val tagChips = available.tags.chips(selections, retained) + val scoreChips = available.passwordScores.chips(selections, retained) + + val effectiveItemTypes = restrictedItemType?.let { setOf(it) } ?: this[FilterFacet.ItemTypes] + val showLoginSection = scoreChips.isNotEmpty() && (effectiveItemTypes.isEmpty() || VaultItemType.Login in effectiveItemTypes) + val itemSection = ItemSectionState( + onlyPinned = onlyPinned, + itemTypeChips = if (showItemTypeChips) itemTypeChips else emptyList(), + tagChips = tagChips, + ) + return FilterBottomSheetState( sortDirection = sortDirection, - itemSection = if (showItemSection) ItemSectionState( - showPinnedSwitch = available.hasPinnedItems, - onlyPinnedChecked = onlyPinned, - itemTypeChips = if (showItemTypeChips) available.itemTypes.map { type -> - FilterChipState(value = type, selected = type in selectedItemTypes) - } else emptyList(), - tagChips = available.tags.map { tag -> - FilterChipState(value = tag, selected = tag in selectedTags) - }, - ) else null, - passwordSection = if (showLoginSection) PasswordSectionState( - passwordScoreChips = available.passwordScores - .sortedByDescending { it.ordinal } - .map { score -> - FilterChipState(value = score, selected = score in selectedScores) - }, - ) else null, + itemSection = itemSection.takeIf { + it.onlyPinned != null || it.itemTypeChips.isNotEmpty() || it.tagChips.isNotEmpty() + }, + passwordSection = if (showLoginSection) PasswordSectionState(scoreChips) else null, isDefault = isDefault, + isVisible = isVisible, ) } -internal fun List.toAvailableFilterOptions( - passwordScores: Map, - tagsByItem: Map>, - allTags: List, -): AvailableFilterOptions { - val itemTypes = fastMapTo(mutableSetOf()) { it.itemType } - val hasLoginItems = VaultItemType.Login in itemTypes - - val itemIds = if (hasLoginItems) fastMapTo(mutableSetOf()) { it.id } - else emptySet() - - val scores = if (hasLoginItems) - passwordScores.filterKeys { it in itemIds }.values.toSet() - else emptySet() - - val visibleTags = buildSet { - this@toAvailableFilterOptions.forEach { item -> - tagsByItem[item.id]?.let(::addAll) - } - } +internal fun FacetSelections.toAvailableFilterOptions(allTags: List): AvailableFilterOptions { + val defaults = AvailableFilterOptions() return AvailableFilterOptions( - itemTypes = itemTypes, - hasPasswordItems = hasLoginItems, - passwordScores = scores, - tags = allTags.filter { it in visibleTags }.toSet(), - hasPinnedItems = any { it.pinned }, + itemTypes = defaults.itemTypes.copy(available = this[FilterFacet.ItemTypes]), + tags = FacetOptions( + facet = FilterFacet.Tags, + order = allTags, + available = allTags.filterTo(mutableSetOf()) { it in this[FilterFacet.Tags] }, + ), + passwordScores = defaults.passwordScores.copy(available = this[FilterFacet.PasswordScores]), + pinned = defaults.pinned.copy(available = this[FilterFacet.Pinned]), ) } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt index 5246e80dd..3386c798d 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt @@ -4,12 +4,34 @@ import androidx.compose.runtime.Immutable import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.feature.list_screen.domain.model.FacetSelections +import de.davis.keygo.feature.list_screen.domain.model.FilterFacet @Immutable internal data class AvailableFilterOptions( - val passwordScores: Set = emptySet(), - val itemTypes: Set = emptySet(), - val tags: Set = emptySet(), - val hasPasswordItems: Boolean = false, - val hasPinnedItems: Boolean = false + val itemTypes: FacetOptions = + FacetOptions(FilterFacet.ItemTypes, VaultItemType.entries), + val tags: FacetOptions = FacetOptions(FilterFacet.Tags, emptyList()), + val passwordScores: FacetOptions = + FacetOptions(FilterFacet.PasswordScores, PasswordScore.entries.reversed()), + val pinned: FacetOptions = FacetOptions(FilterFacet.Pinned, listOf(true)), ) + +@Immutable +internal data class FacetOptions( + val facet: FilterFacet, + val order: List, + val available: Set = emptySet(), +) { + + fun chips( + selections: FacetSelections, + retained: FacetSelections = FacetSelections.None, + ): List> { + val selected = selections[facet] + val shown = available + selected + retained[facet] + return (order + shown).distinct() + .filter { it in shown } + .map { FilterChipState(value = it, selected = it in selected) } + } +} diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt index 64e893a6f..e15b1cb4d 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt @@ -17,8 +17,7 @@ internal data class FilterBottomSheetState( @Immutable internal data class ItemSectionState( - val showPinnedSwitch: Boolean, - val onlyPinnedChecked: Boolean, + val onlyPinned: FilterChipState?, val itemTypeChips: List>, val tagChips: List>, ) diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterStateTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterStateTest.kt index 4ac943b8b..1fd34c701 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterStateTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterStateTest.kt @@ -16,62 +16,48 @@ class FilterStateTest { @Test fun `ascending sort with no scores is default`() { - val state = FilterState( - sortDirection = SortDirection.Ascending, - selectedScores = emptySet(), - ) + val state = FilterState(sortDirection = SortDirection.Ascending) assertTrue(state.isDefault) } @Test fun `descending sort is not default`() { - val state = FilterState( - sortDirection = SortDirection.Descending, - ) + val state = FilterState(sortDirection = SortDirection.Descending) assertFalse(state.isDefault) } @Test fun `non-empty selected scores is not default`() { - val state = FilterState( - selectedScores = setOf(PasswordScore.Weak), - ) + val state = FilterState() + .with(FilterFacet.PasswordScores, setOf(PasswordScore.Weak)) assertFalse(state.isDefault) } @Test fun `both non-default sort and scores is not default`() { - val state = FilterState( - sortDirection = SortDirection.Descending, - selectedScores = setOf(PasswordScore.Strong, PasswordScore.Excellent), - ) + val state = FilterState(sortDirection = SortDirection.Descending) + .with(FilterFacet.PasswordScores, setOf(PasswordScore.Strong, PasswordScore.Excellent)) assertFalse(state.isDefault) } @Test fun `non-empty selected item types is not default`() { - val state = FilterState( - selectedItemTypes = setOf(VaultItemType.Login), - ) + val state = FilterState().with(FilterFacet.ItemTypes, setOf(VaultItemType.Login)) assertFalse(state.isDefault) } @Test fun `non-empty selected tags is not default`() { - val state = FilterState( - selectedTags = setOf(Tag.of("work")!!), - ) + val state = FilterState().with(FilterFacet.Tags, setOf(Tag.of("work")!!)) assertFalse(state.isDefault) } @Test fun `all fields non-default is not default`() { - val state = FilterState( - sortDirection = SortDirection.Descending, - selectedScores = setOf(PasswordScore.Weak), - selectedItemTypes = setOf(VaultItemType.Login), - selectedTags = setOf(Tag.of("personal")!!), - ) + val state = FilterState(sortDirection = SortDirection.Descending) + .with(FilterFacet.PasswordScores, setOf(PasswordScore.Weak)) + .with(FilterFacet.ItemTypes, setOf(VaultItemType.Login)) + .with(FilterFacet.Tags, setOf(Tag.of("personal")!!)) assertFalse(state.isDefault) } } \ No newline at end of file diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCaseTest.kt new file mode 100644 index 000000000..b8e96ed32 --- /dev/null +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCaseTest.kt @@ -0,0 +1,118 @@ +package de.davis.keygo.feature.list_screen.domain.usecase + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.core.item.domain.model.lite.LiteItem +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.feature.list_screen.domain.model.FilterFacet +import de.davis.keygo.feature.list_screen.domain.model.ItemFacets +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AvailableFacetValuesUseCaseTest { + + private val useCase = AvailableFacetValuesUseCase() + + private data class TestLiteItem( + override val name: String, + override val id: ItemId = UUID.nameUUIDFromBytes(name.toByteArray()), + override val itemType: VaultItemType = VaultItemType.Login, + override val pinned: Boolean = false, + ) : LiteItem + + private fun tag(value: String): Tag = Tag.of(value)!! + + @Test + fun `item types include every type present`() { + val result = useCase( + items = listOf( + TestLiteItem("Login"), + TestLiteItem("Card", itemType = VaultItemType.CreditCard), + ), + facets = ItemFacets.None, + ) + + assertEquals( + setOf(VaultItemType.Login, VaultItemType.CreditCard), + result[FilterFacet.ItemTypes], + ) + } + + @Test + fun `tags include only tags carried by a visible item`() { + val a = TestLiteItem("A") + val b = TestLiteItem("B") + + val result = useCase( + items = listOf(a, b), + facets = ItemFacets( + tagsByItem = mapOf( + a.id to setOf(tag("Bank")), + b.id to setOf(tag("Work")), + ), + ), + ) + + assertEquals(setOf(tag("Bank"), tag("Work")), result[FilterFacet.Tags]) + } + + @Test + fun `tags belonging only to items absent from the list are excluded`() { + val a = TestLiteItem("A") + val hidden = TestLiteItem("Hidden") + + val result = useCase( + items = listOf(a), + facets = ItemFacets( + tagsByItem = mapOf( + a.id to setOf(tag("Bank")), + hidden.id to setOf(tag("Secret")), + ), + ), + ) + + assertEquals(setOf(tag("Bank")), result[FilterFacet.Tags]) + } + + @Test + fun `no tags, scores, or pinned flag when nothing carries them`() { + val result = useCase(listOf(TestLiteItem("A")), ItemFacets.None) + + assertTrue(result[FilterFacet.Tags].isEmpty()) + assertTrue(result[FilterFacet.PasswordScores].isEmpty()) + assertTrue(result[FilterFacet.Pinned].isEmpty()) + } + + @Test + fun `scores are drawn only from items typed as logins`() { + val login = TestLiteItem("Login") + val card = TestLiteItem("Card", itemType = VaultItemType.CreditCard) + + val result = useCase( + items = listOf(login, card), + facets = ItemFacets( + passwordScores = mapOf( + login.id to PasswordScore.Strong, + // A score keyed to a non-login id should never happen in practice, but must not + // leak into the available set if it does. + card.id to PasswordScore.Weak, + ), + ), + ) + + assertEquals(setOf(PasswordScore.Strong), result[FilterFacet.PasswordScores]) + } + + @Test + fun `pinned is offered when any item is pinned`() { + val result = useCase( + items = listOf(TestLiteItem("A", pinned = true), TestLiteItem("B")), + facets = ItemFacets.None, + ) + + assertEquals(setOf(true), result[FilterFacet.Pinned]) + } +} diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt index 9fc63d384..9ea3eb893 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt @@ -3,10 +3,13 @@ package de.davis.keygo.feature.list_screen.domain.usecase 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.PasswordScore +import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.model.lite.LiteItem import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.util.domain.usecase.SortUseCase +import de.davis.keygo.feature.list_screen.domain.model.FilterFacet import de.davis.keygo.feature.list_screen.domain.model.FilterState +import de.davis.keygo.feature.list_screen.domain.model.ItemFacets import de.davis.keygo.feature.list_screen.domain.model.SortDirection import java.util.UUID import kotlin.test.Test @@ -34,7 +37,7 @@ class FilterUseCaseTest { fun ascProduces(vararg expected: String) { val provided = items(*items.toTypedArray()) val expected = items(*expected) - val result = useCase(filterStateAsc, provided, emptyMap()) + val result = useCase(filterStateAsc, provided, ItemFacets.None) assertEquals(expected, result) } @@ -42,7 +45,7 @@ class FilterUseCaseTest { fun descProduces(vararg expected: String) { val provided = items(*items.toTypedArray()) val expected = items(*expected) - val result = useCase(filterStateDesc, provided, emptyMap()) + val result = useCase(filterStateDesc, provided, ItemFacets.None) assertEquals(expected, result) } @@ -69,8 +72,9 @@ class FilterUseCaseTest { @Test fun `filtering an empty list returns empty list`() { - val state = FilterState(selectedScores = setOf(PasswordScore.Weak)) - val result = useCase(state, emptyList(), noScores) + val state = FilterState() + .with(FilterFacet.PasswordScores, setOf(PasswordScore.Weak)) + val result = useCase(state, emptyList(), noFacets) assertTrue(result.isEmpty()) } @@ -79,7 +83,7 @@ class FilterUseCaseTest { val result = useCase( filterStateAsc, items("cherry", "Banana", "apple", "Apple", "banana"), - emptyMap() + ItemFacets.None ) // At Collator.PRIMARY, Apple/apple are equal and banana/Banana are equal, so @@ -106,7 +110,7 @@ class FilterUseCaseTest { @Test fun `identical names are stable - order preserved`() { - val result = useCase(filterStateAsc, items("same", "same", "same"), emptyMap()) + val result = useCase(filterStateAsc, items("same", "same", "same"), ItemFacets.None) assertEquals(3, result.size) assertTrue(result.all { it.name == "same" }) } @@ -114,7 +118,8 @@ class FilterUseCaseTest { // Edge: special characters --------------------------------------------- @Test fun `names starting with special characters sort before letters`() { - val result = useCase(filterStateAsc, items("banana", "apple", "/path", "&tag"), emptyMap()) + val result = + useCase(filterStateAsc, items("banana", "apple", "/path", "&tag"), ItemFacets.None) val lastSpecialIndex = maxOf( result.indexOfFirst { it.name == "/path" }, result.indexOfFirst { it.name == "&tag" } @@ -129,15 +134,15 @@ class FilterUseCaseTest { @Test fun `special characters mixed with numbers produce deterministic order`() { val input = items("&2item", "/1item", "#3item") - val result1 = useCase(filterStateAsc, input, emptyMap()) - val result2 = useCase(filterStateAsc, input, emptyMap()) + val result1 = useCase(filterStateAsc, input, ItemFacets.None) + val result2 = useCase(filterStateAsc, input, ItemFacets.None) assertEquals(result1, result2) } @Test fun `backslash and forward slash in names returns all items`() { val input = items("aab", "a/b", "a&b", "a\\b") - val result = useCase(filterStateAsc, input, emptyMap()) + val result = useCase(filterStateAsc, input, ItemFacets.None) assertEquals(4, result.size) assertTrue(result.map { it.name }.containsAll(input.map { it.name })) } @@ -145,8 +150,8 @@ class FilterUseCaseTest { @Test fun `names with only special characters - descending is reverse of ascending`() { val input = items("///", "&&&", "\\\\", "###") - val asc = useCase(filterStateAsc, input, emptyMap()) - val desc = useCase(filterStateDesc, input, emptyMap()) + val asc = useCase(filterStateAsc, input, ItemFacets.None) + val desc = useCase(filterStateDesc, input, ItemFacets.None) assertEquals(asc.reversed(), desc) } @@ -184,14 +189,14 @@ class FilterUseCaseTest { @Test fun `blank and empty names do not crash`() { - assertEquals(3, useCase(filterStateAsc, items("normal", " ", ""), emptyMap()).size) + assertEquals(3, useCase(filterStateAsc, items("normal", " ", ""), ItemFacets.None).size) } // Edge: case sensitivity @Test fun `uppercase and lowercase letters with same prefix are grouped before later letters`() { val result = - useCase(filterStateAsc, items("Banana", "banana", "apple", "Apple"), emptyMap()) + useCase(filterStateAsc, items("Banana", "banana", "apple", "Apple"), ItemFacets.None) val lastAppleIndex = maxOf( result.indexOfFirst { it.name == "apple" }, result.indexOfFirst { it.name == "Apple" } @@ -211,8 +216,8 @@ class FilterUseCaseTest { @Test fun `descending is exact reverse of ascending for unique names`() { val input = items("file2", "file10", "file1", "abc", "10abc") - val asc = useCase(filterStateAsc, input, emptyMap()) - val desc = useCase(filterStateDesc, input, emptyMap()) + val asc = useCase(filterStateAsc, input, ItemFacets.None) + val desc = useCase(filterStateDesc, input, ItemFacets.None) assertEquals(asc.reversed(), desc) } @@ -220,15 +225,18 @@ class FilterUseCaseTest { @Test fun `fully mixed names produce deterministic order`() { val input = items("10", "abc", "/path", "2file", "FILE3", "&special", "1") - val result1 = useCase(filterStateAsc, input, emptyMap()) - val result2 = useCase(filterStateAsc, input, emptyMap()) + val result1 = useCase(filterStateAsc, input, ItemFacets.None) + val result2 = useCase(filterStateAsc, input, ItemFacets.None) assertEquals(result1, result2) } @Test fun `names with emoji do not crash`() { - assertEquals(3, useCase(filterStateAsc, items("banana", "🍌2", "🍎 apple"), emptyMap()).size) + assertEquals( + 3, + useCase(filterStateAsc, items("banana", "🍌2", "🍎 apple"), ItemFacets.None).size + ) } // Filter by item type @@ -237,19 +245,19 @@ class FilterUseCaseTest { TestLiteItem(name = "Login B", id = newItemId(), itemType = VaultItemType.Login), ) - private val noScores: Map = emptyMap() + private val noFacets = ItemFacets.None @Test fun `no item type selected returns all items`() { - val state = FilterState(selectedItemTypes = emptySet()) - val result = useCase(state, typedItems, noScores) + val state = FilterState() + val result = useCase(state, typedItems, noFacets) assertEquals(result, typedItems) } @Test fun `selecting Login type keeps only logins`() { - val state = FilterState(selectedItemTypes = setOf(VaultItemType.Login)) - val result = useCase(state, typedItems, noScores) + val state = FilterState().with(FilterFacet.ItemTypes, setOf(VaultItemType.Login)) + val result = useCase(state, typedItems, noFacets) assertTrue(result.all { it.itemType == VaultItemType.Login }) } @@ -268,19 +276,22 @@ class FilterUseCaseTest { scoredItems[3].id to PasswordScore.Weak, ) + private val scoreFacets = ItemFacets(passwordScores = scores) + @Test fun `no score selected returns all login items`() { - val state = FilterState(selectedScores = emptySet()) + val state = FilterState() val result = - useCase(state, scoredItems, scores).filter { it.itemType == VaultItemType.Login } + useCase(state, scoredItems, scoreFacets).filter { it.itemType == VaultItemType.Login } val expected = scoredItems.filter { it.itemType == VaultItemType.Login } assertEquals(expected, result) } @Test fun `selecting single score returns matching items`() { - val state = FilterState(selectedScores = setOf(PasswordScore.Weak)) - val result = useCase(state, scoredItems, scores) + val state = FilterState() + .with(FilterFacet.PasswordScores, setOf(PasswordScore.Weak)) + val result = useCase(state, scoredItems, scoreFacets) val expected = scoredItems.filter { scores[it.id] == PasswordScore.Weak } assertEquals(expected, result) @@ -288,10 +299,9 @@ class FilterUseCaseTest { @Test fun `selecting multiple scores returns union of matches`() { - val state = FilterState( - selectedScores = setOf(PasswordScore.Weak, PasswordScore.Excellent), - ) - val result = useCase(state, scoredItems, scores) + val state = FilterState() + .with(FilterFacet.PasswordScores, setOf(PasswordScore.Weak, PasswordScore.Excellent)) + val result = useCase(state, scoredItems, scoreFacets) val expected = scoredItems.filter { scores[it.id] == PasswordScore.Weak || @@ -304,33 +314,31 @@ class FilterUseCaseTest { @Test fun `selecting a score not present in any item returns empty list`() { - val state = FilterState(selectedScores = setOf(PasswordScore.Ridiculous)) - val result = useCase(state, scoredItems, scores) + val state = FilterState() + .with(FilterFacet.PasswordScores, setOf(PasswordScore.Ridiculous)) + val result = useCase(state, scoredItems, scoreFacets) assertTrue(result.isEmpty()) } // Combined filters @Test fun `item type and score filters are applied together`() { - val state = FilterState( - selectedItemTypes = setOf(VaultItemType.Login), - selectedScores = setOf(PasswordScore.Excellent), - ) - val result = useCase(state, scoredItems, scores) + val state = FilterState() + .with(FilterFacet.ItemTypes, setOf(VaultItemType.Login)) + .with(FilterFacet.PasswordScores, setOf(PasswordScore.Excellent)) + val result = useCase(state, scoredItems, scoreFacets) assertTrue(result.all { it.itemType == VaultItemType.Login }) assertTrue(result.all { scores[it.id] == PasswordScore.Excellent }) } @Test fun `filter result is still sorted by sort direction`() { - val state = FilterState( - sortDirection = SortDirection.Descending, - selectedScores = setOf(PasswordScore.Weak, PasswordScore.Strong), - ) - val result = useCase(state, scoredItems, scores) + val state = FilterState(sortDirection = SortDirection.Descending) + .with(FilterFacet.PasswordScores, setOf(PasswordScore.Weak, PasswordScore.Strong)) + val result = useCase(state, scoredItems, scoreFacets) val ascState = state.copy(sortDirection = SortDirection.Ascending) - val ascResult = useCase(ascState, scoredItems, scores) + val ascResult = useCase(ascState, scoredItems, scoreFacets) assertEquals(ascResult.reversed(), result) } @@ -345,7 +353,7 @@ class FilterUseCaseTest { @Test fun `pinned items appear before unpinned items`() { - val result = useCase(filterStateAsc, mixedPinnedItems, noScores) + val result = useCase(filterStateAsc, mixedPinnedItems, noFacets) val pinnedNames = result.takeWhile { it.pinned }.map { it.name } val unpinnedNames = result.dropWhile { it.pinned }.map { it.name } @@ -356,7 +364,7 @@ class FilterUseCaseTest { @Test fun `pinned items appear before unpinned items in descending order`() { - val result = useCase(filterStateDesc, mixedPinnedItems, noScores) + val result = useCase(filterStateDesc, mixedPinnedItems, noFacets) val pinnedNames = result.takeWhile { it.pinned }.map { it.name } val unpinnedNames = result.dropWhile { it.pinned }.map { it.name } @@ -367,8 +375,8 @@ class FilterUseCaseTest { @Test fun `onlyPinned filter excludes unpinned items`() { - val state = FilterState(onlyPinned = true) - val result = useCase(state, mixedPinnedItems, noScores) + val state = FilterState().with(FilterFacet.Pinned, setOf(true)) + val result = useCase(state, mixedPinnedItems, noFacets) assertTrue(result.all { it.pinned }) assertEquals(2, result.size) @@ -376,28 +384,27 @@ class FilterUseCaseTest { @Test fun `onlyPinned false returns all items`() { - val state = FilterState(onlyPinned = false) - val result = useCase(state, mixedPinnedItems, noScores) + val state = FilterState() + val result = useCase(state, mixedPinnedItems, noFacets) assertEquals(4, result.size) } @Test fun `onlyPinned with no pinned items returns empty list`() { - val state = FilterState(onlyPinned = true) + val state = FilterState().with(FilterFacet.Pinned, setOf(true)) val unpinnedOnly = items("A", "B", "C") - val result = useCase(state, unpinnedOnly, noScores) + val result = useCase(state, unpinnedOnly, noFacets) assertTrue(result.isEmpty()) } @Test fun `onlyPinned combined with item type filter`() { - val state = FilterState( - onlyPinned = true, - selectedItemTypes = setOf(VaultItemType.Login), - ) - val result = useCase(state, mixedPinnedItems, noScores) + val state = FilterState() + .with(FilterFacet.Pinned, setOf(true)) + .with(FilterFacet.ItemTypes, setOf(VaultItemType.Login)) + val result = useCase(state, mixedPinnedItems, noFacets) assertTrue(result.all { it.pinned }) assertTrue(result.all { it.itemType == VaultItemType.Login }) @@ -410,47 +417,80 @@ class FilterUseCaseTest { TestLiteItem(name = "Alpha", pinned = true), TestLiteItem(name = "Mike", pinned = true), ) - val result = useCase(filterStateAsc, allPinned, noScores) + val result = useCase(filterStateAsc, allPinned, noFacets) assertEquals(listOf("Alpha", "Mike", "Zulu"), result.map { it.name }) } - // Filter by tag (precomputed matching ids) + // Filter by tag (from the item -> tags map) private val tagItems = listOf( TestLiteItem(name = "Bank A", id = newItemId()), TestLiteItem(name = "Bank B", id = newItemId()), TestLiteItem(name = "Work C", id = newItemId()), ) + private val bankTagged = FilterState().with(FilterFacet.Tags, setOf(Tag.of("Bank")!!)) + @Test - fun `null tagMatchingIds returns all items`() { - val result = useCase(FilterState(), tagItems, noScores, null) - assertEquals(tagItems.size, result.size) - assertTrue(result.containsAll(tagItems)) + fun `an item carrying the selected tag passes while others are dropped`() { + val facets = ItemFacets( + tagsByItem = mapOf( + tagItems[0].id to setOf(Tag.of("Bank")!!), + // tagItems[1] carries no tags entry at all. + tagItems[2].id to setOf(Tag.of("Work")!!), + ), + ) + val result = useCase(bankTagged, tagItems, facets) + + assertEquals(listOf(tagItems[0].id), result.map { it.id }) } @Test - fun `tagMatchingIds keeps only items whose id is in the set`() { - val matching = setOf(tagItems[0].id, tagItems[1].id) - val result = useCase(FilterState(), tagItems, noScores, matching) + fun `two selected tags keep items carrying either`() { + val state = FilterState() + .with(FilterFacet.Tags, setOf(Tag.of("Bank")!!, Tag.of("Work")!!)) + val facets = ItemFacets( + tagsByItem = mapOf( + tagItems[0].id to setOf(Tag.of("Bank")!!), + tagItems[1].id to setOf(Tag.of("Personal")!!), + tagItems[2].id to setOf(Tag.of("Work")!!), + ), + ) + val result = useCase(state, tagItems, facets) - assertEquals(2, result.size) - assertTrue(result.all { it.id in matching }) + assertEquals(setOf(tagItems[0].id, tagItems[2].id), result.mapTo(mutableSetOf()) { it.id }) } @Test - fun `empty tagMatchingIds returns empty list`() { - val result = useCase(FilterState(), tagItems, noScores, emptySet()) - assertTrue(result.isEmpty()) + fun `a selected tag matches an item tagged with different casing`() { + val state = FilterState().with(FilterFacet.Tags, setOf(Tag.of("bank")!!)) + val facets = ItemFacets(tagsByItem = mapOf(tagItems[0].id to setOf(Tag.of("Bank")!!))) + val result = useCase(state, tagItems, facets) + + assertEquals(listOf(tagItems[0].id), result.map { it.id }) } @Test fun `tag filter combines with item type filter`() { - val state = FilterState(selectedItemTypes = setOf(VaultItemType.Login)) - val matching = setOf(tagItems[0].id) - val result = useCase(state, tagItems, noScores, matching) + val loginBank = TestLiteItem(name = "Login Bank", itemType = VaultItemType.Login) + val cardBank = TestLiteItem(name = "Card Bank", itemType = VaultItemType.CreditCard) + val state = bankTagged.with(FilterFacet.ItemTypes, setOf(VaultItemType.Login)) + val facets = ItemFacets( + tagsByItem = mapOf( + loginBank.id to setOf(Tag.of("Bank")!!), + cardBank.id to setOf(Tag.of("Bank")!!), + ), + ) + val result = useCase(state, listOf(loginBank, cardBank), facets) - assertEquals(1, result.size) - assertEquals(tagItems[0].id, result.single().id) + assertEquals(listOf(loginBank.id), result.map { it.id }) + } + + @Test + fun `no Tags selection keeps untagged items`() { + val result = useCase(FilterState(), tagItems, noFacets) + + assertEquals(tagItems.size, result.size) + assertTrue(result.containsAll(tagItems)) } } \ No newline at end of file diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt new file mode 100644 index 000000000..73dd1dac7 --- /dev/null +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt @@ -0,0 +1,165 @@ +package de.davis.keygo.feature.list_screen.domain.usecase + +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakeLoginRepository +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.EncryptedPayload +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.PasswordCredential +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.PasswordSecret +import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.core.item.domain.model.Timestamp +import de.davis.keygo.core.util.domain.usecase.SortUseCase +import de.davis.keygo.feature.list_screen.domain.model.FilterFacet +import de.davis.keygo.feature.list_screen.domain.model.FilterResult +import de.davis.keygo.feature.list_screen.domain.model.FilterState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class ObserveFilterResultUseCaseTest { + + private val loginRepository = FakeLoginRepository() + private val itemRepository = FakeItemRepository(loginRepository) + private val sortUseCase = SortUseCase() + + private val useCase = ObserveFilterResultUseCase( + itemRepository = itemRepository, + loginRepository = loginRepository, + filterUseCase = FilterUseCase(sortUseCase), + availableFacetValues = AvailableFacetValuesUseCase(), + ) + + private fun login( + name: String, + id: ItemId = newItemId(), + vault: VaultId = newVaultId(), + tags: Set = emptySet(), + passwordCredential: PasswordCredential? = null, + ) = Login( + id = id, + name = name, + username = null, + domainInfos = emptySet(), + passwordCredential = passwordCredential, + totp = null, + passkeys = emptySet(), + note = null, + pinned = false, + vaultId = vault, + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + timestamp = Timestamp(), + tags = tags, + ) + + private fun credential(score: PasswordScore) = + PasswordCredential(secret = PasswordSecret(EncryptedPayload.EMPTY), score = score) + + @Test + fun `filters by a tag seeded in the fake store`() = runTest { + val bank = Tag.of("Bank")!! + val tagged = login("Tagged", tags = setOf(bank)) + val untagged = login("Untagged") + loginRepository.seed(tagged, untagged) + + val items = MutableStateFlow(listOf(tagged, untagged)) + val filterState = MutableStateFlow( + FilterState().with(FilterFacet.Tags, setOf(bank)), + ) + + val result = useCase(items, filterState).first() + + assertEquals(listOf(tagged.id), result.items.map { it.id }) + } + + @Test + fun `available reflects the unfiltered items, so a value the filter hides is still offered`() = + runTest { + val bank = Tag.of("Bank")!! + val work = Tag.of("Work")!! + val tagged = login("Tagged", tags = setOf(bank)) + val other = login("Other", tags = setOf(work)) + loginRepository.seed(tagged, other) + + val items = MutableStateFlow(listOf(tagged, other)) + val filterState = MutableStateFlow( + FilterState().with(FilterFacet.Tags, setOf(bank)), + ) + + val result = useCase(items, filterState).first() + + assertEquals(listOf(tagged.id), result.items.map { it.id }) + assertEquals(setOf(bank, work), result.available[FilterFacet.Tags]) + } + + @Test + fun `a filter hiding every item reports empty because of the filter`() = runTest { + val loose = login("Loose") + loginRepository.seed(loose) + + val items = MutableStateFlow(listOf(loose)) + val filterState = MutableStateFlow( + FilterState().with(FilterFacet.Pinned, setOf(true)), + ) + + val result = useCase(items, filterState).first() + + assertTrue(result.items.isEmpty()) + assertTrue(result.isEmptyBecauseOfFilter) + } + + @Test + fun `an empty source is not empty because of the filter`() = runTest { + val items = MutableStateFlow(emptyList()) + val filterState = MutableStateFlow( + FilterState().with(FilterFacet.Pinned, setOf(true)), + ) + + val result = useCase(items, filterState).first() + + assertTrue(result.items.isEmpty()) + assertFalse(result.isEmptyBecauseOfFilter) + } + + @Test + fun `re-emits when a password score changes in the login repository`() = runTest { + val item = login("Scored", passwordCredential = credential(PasswordScore.Weak)) + loginRepository.seed(item) + + val items = MutableStateFlow(listOf(item)) + val filterState = MutableStateFlow(FilterState()) + + val results = mutableListOf>() + val job = launch { useCase(items, filterState).toList(results) } + + runCurrent() + loginRepository.seed(item.copy(passwordCredential = credential(PasswordScore.Excellent))) + runCurrent() + + job.cancel() + + assertTrue(results.size >= 2) + assertEquals( + setOf(PasswordScore.Weak), + results.first().available[FilterFacet.PasswordScores] + ) + assertEquals( + setOf(PasswordScore.Excellent), + results.last().available[FilterFacet.PasswordScores] + ) + } +} 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 2c73bed9c..8adb0e4d1 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 @@ -14,14 +14,19 @@ import de.davis.keygo.core.item.domain.model.CardExpiryStatus import de.davis.keygo.core.item.domain.model.CreditCard import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.Tag 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.model.FilterFacet +import de.davis.keygo.feature.list_screen.domain.usecase.AvailableFacetValuesUseCase import de.davis.keygo.feature.list_screen.domain.usecase.FilterUseCase import de.davis.keygo.feature.list_screen.domain.usecase.ObserveCardExpiryStatusesUseCase +import de.davis.keygo.feature.list_screen.domain.usecase.ObserveFilterResultUseCase import de.davis.keygo.feature.list_screen.domain.usecase.RankSearchResultsUseCase import de.davis.keygo.feature.list_screen.presentation.model.Event import de.davis.keygo.feature.list_screen.presentation.model.FilterAction +import de.davis.keygo.feature.list_screen.presentation.model.FilterChipState import de.davis.keygo.feature.vault.domain.usecase.ObserveVaultsAndSelectionUseCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -40,6 +45,7 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue /** @@ -69,7 +75,6 @@ class ItemListViewModelTest { enableSelection = enableSelection, restrictedItemType = null, itemRepository = itemRepository, - filterUseCase = FilterUseCase(sortUseCase), rankSearchResults = RankSearchResultsUseCase(sortUseCase), observeAllTags = ObserveAllTagsSortedUseCase(itemRepository, sortUseCase), observeVaultsAndSelection = ObserveVaultsAndSelectionUseCase( @@ -77,8 +82,13 @@ class ItemListViewModelTest { vaultContextRepository = vaultContextRepository, sortUseCase = sortUseCase, ), + observeFilterResult = ObserveFilterResultUseCase( + itemRepository = itemRepository, + loginRepository = loginRepository, + filterUseCase = FilterUseCase(sortUseCase), + availableFacetValues = AvailableFacetValuesUseCase(), + ), observeCardExpiryStatuses = ObserveCardExpiryStatusesUseCase(creditCardRepository), - loginRepository = loginRepository, ) private fun login( @@ -86,6 +96,7 @@ class ItemListViewModelTest { id: ItemId = newItemId(), vault: VaultId = vaultId, pinned: Boolean = false, + tags: Set = emptySet(), ) = Login( id = id, name = name, @@ -99,6 +110,7 @@ class ItemListViewModelTest { vaultId = vault, keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), timestamp = Timestamp(), + tags = tags, ) private suspend fun storedIds(): Set = @@ -435,7 +447,7 @@ class ItemListViewModelTest { val vm = viewModel() backgroundScope.launchCollect(vm) - vm.onFilterAction(FilterAction.ShowOnlyPinnedToggled) + vm.onFilterAction(FilterAction.Toggled(FilterFacet.Pinned, true)) vm.onItemLongClick(pinned.id) vm.onDeleteSelectedRequest() vm.onConfirmDeleteSelected() @@ -449,12 +461,76 @@ class ItemListViewModelTest { fun `an empty vault is not empty because of the filter`() = runTest(dispatcher) { val vm = viewModel() backgroundScope.launchCollect(vm) - vm.onFilterAction(FilterAction.ShowOnlyPinnedToggled) + vm.onFilterAction(FilterAction.Toggled(FilterFacet.Pinned, true)) advanceUntilIdle() assertFalse(vm.listItemState.value.isEmptyBecauseOfFilter) } + @Test + fun `a filter nothing matches any more keeps its control until deselected and the sheet closes`() = + runTest(dispatcher) { + loginRepository.seed(login("Loose")) + + val vm = viewModel() + backgroundScope.launchCollect(vm) + vm.onFilterAction(FilterAction.Toggled(FilterFacet.Pinned, true)) + vm.onShowFilterSheet() + advanceUntilIdle() + + assertEquals( + FilterChipState(value = true, selected = true), + vm.filterBottomSheetState.value.itemSection?.onlyPinned, + ) + + vm.onFilterAction(FilterAction.Toggled(FilterFacet.Pinned, true)) + advanceUntilIdle() + + assertEquals( + FilterChipState(value = true, selected = false), + vm.filterBottomSheetState.value.itemSection?.onlyPinned, + ) + + vm.onDismissFilterSheet() + advanceUntilIdle() + + assertFalse(vm.filterBottomSheetState.value.isVisible) + assertNull(vm.filterBottomSheetState.value.itemSection?.onlyPinned) + } + + @Test + fun `clearing filters outside the sheet drops the controls nothing matches right away`() = + runTest(dispatcher) { + loginRepository.seed(login("Loose")) + + val vm = viewModel() + backgroundScope.launchCollect(vm) + vm.onFilterAction(FilterAction.Toggled(FilterFacet.Pinned, true)) + vm.onFilterAction(FilterAction.ClearFilters) + advanceUntilIdle() + + assertTrue(vm.filterBottomSheetState.value.isDefault) + assertNull(vm.filterBottomSheetState.value.itemSection?.onlyPinned) + assertEquals(1, vm.listItemState.value.items.size) + } + + @Test + fun `selecting a tag narrows the list to items carrying it`() = runTest(dispatcher) { + val bank = Tag.of("Bank")!! + val tagged = login("Tagged", tags = setOf(bank)) + val untagged = login("Untagged") + loginRepository.seed(tagged, untagged) + + val vm = viewModel() + backgroundScope.launchCollect(vm) + advanceUntilIdle() + + vm.onFilterAction(FilterAction.Toggled(FilterFacet.Tags, bank)) + advanceUntilIdle() + + assertEquals(setOf(tagged.id), vm.listItemState.value.items.mapTo(mutableSetOf()) { it.id }) + } + @Test fun `an expired card carries its status into the list state`() = runTest(dispatcher) { val card = expiredCard() @@ -492,4 +568,5 @@ class ItemListViewModelTest { */ private fun CoroutineScope.launchCollect(vm: ItemListViewModel) { launch { vm.listItemState.collect { } } + launch { vm.filterBottomSheetState.collect { } } } diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapperTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapperTest.kt index 92d7a94e7..29e048a71 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapperTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapperTest.kt @@ -1,92 +1,177 @@ package de.davis.keygo.feature.list_screen.presentation.mapper -import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag -import de.davis.keygo.core.item.domain.model.lite.LiteItem import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.feature.list_screen.domain.model.FacetSelections +import de.davis.keygo.feature.list_screen.domain.model.FilterFacet import de.davis.keygo.feature.list_screen.domain.model.FilterState -import java.util.UUID +import de.davis.keygo.feature.list_screen.presentation.model.FilterBottomSheetState +import de.davis.keygo.feature.list_screen.presentation.model.FilterChipState import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue class FilterMapperTest { - private data class TestLiteItem( - override val name: String, - override val id: ItemId = UUID.nameUUIDFromBytes(name.toByteArray()), - override val itemType: VaultItemType = VaultItemType.Login, - override val pinned: Boolean = false, - ) : LiteItem + private fun tag(value: String): Tag = Tag.of(value)!! - private val noScores: Map = emptyMap() - private val noTags: Map> = emptyMap() + // Every sheetFor() call defaults to one available Login item, matching the shape + // AvailableFacetValuesUseCase would produce for a single, unpinned, untagged login. + private val defaultFacets = + FacetSelections.None.with(FilterFacet.ItemTypes, setOf(VaultItemType.Login)) - private fun tag(value: String): Tag = Tag.of(value)!! + private fun sheetFor( + filter: FilterState, + facets: FacetSelections = defaultFacets, + retained: FacetSelections = FacetSelections.None, + ) = filter.toBottomSheetState( + available = facets.toAvailableFilterOptions(allTags = emptyList()), + restrictedItemType = null, + retained = retained, + ) + + private fun scoresAvailable(vararg scores: PasswordScore): FacetSelections = + defaultFacets.with(FilterFacet.PasswordScores, scores.toSet()) + + private fun FilterBottomSheetState.scoreChips() = + passwordSection?.passwordScoreChips.orEmpty().map { it.value to it.selected } @Test - fun `tags include only tags carried by a visible item, in allTags order`() { - val a = TestLiteItem("A") - val b = TestLiteItem("B") - - val options = listOf(a, b).toAvailableFilterOptions( - passwordScores = noScores, - tagsByItem = mapOf( - a.id to setOf(tag("Bank")), - b.id to setOf(tag("Work")), - ), - // "Personal" belongs to no visible item -> must be excluded. - allTags = listOf(tag("Bank"), tag("Personal"), tag("Work")), + fun `tags are ordered by allTags and limited to the values present in the selections`() { + val available = FacetSelections.None + .with(FilterFacet.Tags, setOf(tag("Bank"), tag("Work"))) + // "Personal" is not among the selections' tags -> must be excluded. + .toAvailableFilterOptions(allTags = listOf(tag("Bank"), tag("Personal"), tag("Work"))) + + assertEquals(listOf(tag("Bank"), tag("Work")), available.tags.available.toList()) + } + + @Test + fun `selected tags are reflected as selected chips`() { + val available = FacetSelections.None + .with(FilterFacet.Tags, setOf(tag("Bank"), tag("Work"))) + .toAvailableFilterOptions(allTags = listOf(tag("Bank"), tag("Work"))) + + val sheet = FilterState().with(FilterFacet.Tags, setOf(tag("Bank"))) + .toBottomSheetState(available, restrictedItemType = null) + + val chips = sheet.itemSection?.tagChips.orEmpty() + assertEquals(setOf(tag("Bank"), tag("Work")), chips.map { it.value }.toSet()) + assertTrue(chips.single { it.value == tag("Bank") }.selected) + assertTrue(!chips.single { it.value == tag("Work") }.selected) + } + + @Test + fun `a selected score no item carries any more keeps its chip, still selected`() { + val sheet = sheetFor( + FilterState().with(FilterFacet.PasswordScores, setOf(PasswordScore.Weak)), + scoresAvailable(PasswordScore.Strong), ) - // Order follows the (already sorted) allTags list. - assertEquals(listOf(tag("Bank"), tag("Work")), options.tags.toList()) + assertEquals( + listOf(PasswordScore.Strong to false, PasswordScore.Weak to true), + sheet.scoreChips(), + ) } @Test - fun `tags belonging only to items absent from the list are excluded`() { - val a = TestLiteItem("A") - val hidden = TestLiteItem("Hidden") - - val options = listOf(a).toAvailableFilterOptions( - passwordScores = noScores, - tagsByItem = mapOf( - a.id to setOf(tag("Bank")), - hidden.id to setOf(tag("Secret")), + fun `a retained score keeps its chip unselected, in display order`() { + val sheet = sheetFor( + FilterState(), + scoresAvailable(PasswordScore.Excellent, PasswordScore.Ridiculous), + retained = FacetSelections.None.with( + FilterFacet.PasswordScores, + setOf(PasswordScore.Weak) ), - allTags = listOf(tag("Bank"), tag("Secret")), ) - assertEquals(setOf(tag("Bank")), options.tags) + assertEquals( + listOf( + PasswordScore.Excellent to false, + PasswordScore.Weak to false, + PasswordScore.Ridiculous to false, + ), + sheet.scoreChips(), + ) } @Test - fun `no tags when no visible item has tags`() { - val options = listOf(TestLiteItem("A")).toAvailableFilterOptions( - passwordScores = noScores, - tagsByItem = noTags, - allTags = listOf(tag("Bank")), + fun `a deselected score no item carries loses its chip once nothing retains it`() { + val sheet = sheetFor(FilterState(), scoresAvailable(PasswordScore.Strong)) + + assertEquals(listOf(PasswordScore.Strong to false), sheet.scoreChips()) + } + + @Test + fun `the login section stays for a selected score after the last login is gone`() { + val sheet = sheetFor( + FilterState().with(FilterFacet.PasswordScores, setOf(PasswordScore.Weak)), + facets = FacetSelections.None.with( + FilterFacet.ItemTypes, + setOf(VaultItemType.CreditCard) + ), ) - assertTrue(options.tags.isEmpty()) + assertEquals(listOf(PasswordScore.Weak to true), sheet.scoreChips()) } @Test - fun `selected tags are reflected as selected chips`() { - val a = TestLiteItem("A") - val available = listOf(a).toAvailableFilterOptions( - passwordScores = noScores, - tagsByItem = mapOf(a.id to setOf(tag("Bank"), tag("Work"))), - allTags = listOf(tag("Bank"), tag("Work")), + fun `the pinned switch stays while only pinned is on and nothing is pinned`() { + val sheet = sheetFor(FilterState().with(FilterFacet.Pinned, setOf(true))) + + assertEquals(FilterChipState(value = true, selected = true), sheet.itemSection?.onlyPinned) + } + + @Test + fun `no pinned switch when nothing is pinned and the filter is off`() { + assertNull(sheetFor(FilterState()).itemSection?.onlyPinned) + } + + @Test + fun `a single item type offers no chips`() { + assertTrue(sheetFor(FilterState()).itemSection?.itemTypeChips.orEmpty().isEmpty()) + } + + @Test + fun `item type chips stay while the only type left is selected`() { + val sheet = sheetFor(FilterState().with(FilterFacet.ItemTypes, setOf(VaultItemType.Login))) + + assertEquals( + listOf(FilterChipState(value = VaultItemType.Login, selected = true)), + sheet.itemSection?.itemTypeChips, ) + } - val sheet = FilterState(selectedTags = setOf(tag("Bank"))) + @Test + fun `item type chips stay while the sheet retains the only type left`() { + val sheet = sheetFor( + FilterState(), + retained = FacetSelections.None.with(FilterFacet.ItemTypes, setOf(VaultItemType.Login)), + ) + + assertEquals( + listOf(FilterChipState(value = VaultItemType.Login, selected = false)), + sheet.itemSection?.itemTypeChips, + ) + } + + @Test + fun `a selected tag no item carries any more is listed after the live tags`() { + val available = FacetSelections.None + .with(FilterFacet.Tags, setOf(tag("Work"))) + .toAvailableFilterOptions(allTags = listOf(tag("Work"))) + + val sheet = FilterState().with(FilterFacet.Tags, setOf(tag("Bank"))) .toBottomSheetState(available, restrictedItemType = null) - val chips = sheet.itemSection?.tagChips.orEmpty() - assertEquals(setOf(tag("Bank"), tag("Work")), chips.map { it.value }.toSet()) - assertTrue(chips.single { it.value == tag("Bank") }.selected) - assertTrue(!chips.single { it.value == tag("Work") }.selected) + assertEquals( + listOf( + FilterChipState(value = tag("Work"), selected = false), + FilterChipState(value = tag("Bank"), selected = true), + ), + sheet.itemSection?.tagChips, + ) } } From 21a7df8017f5fef8cc557270e298bd2fa5661d31 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 29 Sep 2026 15:34:25 +0200 Subject: [PATCH 5/9] refactor(repository): remove observeItemIdsForTags method and related queries for cleaner code --- .../keygo/core/item/data/local/dao/TagDao.kt | 9 ---- .../data/repository/ItemRepositoryImpl.kt | 4 -- .../item/domain/repository/ItemRepository.kt | 2 - .../core/item/data/local/dao/TagDaoTest.kt | 47 ------------------- .../keygo/core/item/FakeItemRepository.kt | 10 ---- 5 files changed, 72 deletions(-) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/TagDao.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/TagDao.kt index bdf5e2ce1..095b187b6 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/TagDao.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/TagDao.kt @@ -29,15 +29,6 @@ internal abstract class TagDao { @Query("SELECT * FROM tag WHERE EXISTS (SELECT 1 FROM tag_cross_ref WHERE tag_id = tag.id)") abstract fun observeAllTags(): Flow> - @Query( - """ - SELECT DISTINCT x.item_id FROM tag_cross_ref x - JOIN tag t ON t.id = x.tag_id - WHERE t.normalized IN (:normalizedValues) - """ - ) - abstract fun observeItemIdsWithAnyTag(normalizedValues: Set): Flow> - @Query( """ SELECT x.item_id AS itemId, t.value AS value FROM tag_cross_ref x diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/ItemRepositoryImpl.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/ItemRepositoryImpl.kt index abafce64d..67c232e04 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/ItemRepositoryImpl.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/ItemRepositoryImpl.kt @@ -72,10 +72,6 @@ internal class ItemRepositoryImpl( override fun observeAllTags(): Flow> = tagDao.observeAllTags().map { it.map(TagEntity::toDomain) } - override fun observeItemIdsForTags(tags: Set): Flow> = - tagDao.observeItemIdsWithAnyTag(tags.mapTo(mutableSetOf()) { it.normalized }) - .map { it.toSet() } - override fun observeTagsByItem(): Flow>> = tagDao.observeItemTags().map { rows -> rows.groupBy { it.itemId } diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/ItemRepository.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/ItemRepository.kt index a3e8fb0b5..c3f70ef46 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/ItemRepository.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/ItemRepository.kt @@ -35,8 +35,6 @@ interface ItemRepository { fun observeAllTags(): Flow> - fun observeItemIdsForTags(tags: Set): Flow> - fun observeTagsByItem(): Flow>> fun observeLiteVaultItems(vaultId: VaultId? = null): Flow> diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/TagDaoTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/TagDaoTest.kt index a68e408b1..1172f2813 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/TagDaoTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/TagDaoTest.kt @@ -134,53 +134,6 @@ internal class TagDaoTest { assertNull(tagDao.findIdByNormalized("temp")) } - @Test - fun `observeItemIdsWithAnyTag returns ids of items having any of the values`() = runTest { - val a = insertItem() - val b = insertItem() - val c = insertItem() - tagDao.syncTags(a, setOf(tag("Bank"))) - tagDao.syncTags(b, setOf(tag("Work"), tag("Bank"))) - tagDao.syncTags(c, setOf(tag("Personal"))) - - val ids = tagDao.observeItemIdsWithAnyTag(setOf("bank")).first().toSet() - - assertEquals(setOf(a, b), ids) - } - - @Test - fun `observeItemIdsWithAnyTag unions multiple values`() = runTest { - val a = insertItem() - val b = insertItem() - val c = insertItem() - tagDao.syncTags(a, setOf(tag("Bank"))) - tagDao.syncTags(b, setOf(tag("Work"))) - tagDao.syncTags(c, setOf(tag("Personal"))) - - val ids = tagDao.observeItemIdsWithAnyTag(setOf("bank", "work")).first().toSet() - - assertEquals(setOf(a, b), ids) - } - - @Test - fun `observeItemIdsWithAnyTag returns empty when no tag matches`() = runTest { - val a = insertItem() - tagDao.syncTags(a, setOf(tag("Bank"))) - - assertEquals(emptyList(), tagDao.observeItemIdsWithAnyTag(setOf("nope")).first()) - } - - @Test - fun `observeItemIdsWithAnyTag matches the stored normalized form regardless of display casing`() = - runTest { - val a = insertItem() - tagDao.syncTags(a, setOf(tag("Bank"))) - - // Caller is required to pass normalized (lower-cased, trimmed) values. - assertEquals(listOf(a), tagDao.observeItemIdsWithAnyTag(setOf("bank")).first()) - assertEquals(emptyList(), tagDao.observeItemIdsWithAnyTag(setOf("Bank")).first()) - } - @Test fun `observeItemTags emits one row per item-tag pair using display value`() = runTest { val a = insertItem() 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 4158f9861..71bfac2b6 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 @@ -51,16 +51,6 @@ class FakeItemRepository( override fun observeAllTags(): Flow> = allStores.map { store -> store.values.flatMap { item -> item.tags }.distinct() } - override fun observeItemIdsForTags(tags: Set): Flow> { - val targetNormalized = tags.mapTo(mutableSetOf()) { it.normalized } - return allStores.map { store -> - store.values - .filter { item -> item.tags.any { it.normalized in targetNormalized } } - .map { it.id } - .toSet() - } - } - override fun observeTagsByItem(): Flow>> = allStores.map { store -> store.values From a6d3abf360652d79e5ed13c74d0cf8dcb138c55d Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Tue, 29 Sep 2026 15:47:05 +0200 Subject: [PATCH 6/9] feat(facets): streamline FacetSelections and FilterFacet implementations for improved clarity and performance --- .../domain/model/FacetSelections.kt | 2 - .../list_screen/domain/model/FilterFacet.kt | 36 ++++++- .../list_screen/domain/model/FilterState.kt | 2 - .../{ItemFacets.kt => ItemAttributes.kt} | 6 +- .../usecase/AvailableFacetValuesUseCase.kt | 33 +++--- .../domain/usecase/FilterUseCase.kt | 28 ++--- .../usecase/ObserveFilterResultUseCase.kt | 12 +-- .../components/FilterBottomSheet.kt | 20 ++-- .../presentation/mapper/FilterMapper.kt | 39 +++---- .../model/AvailableFilterOptions.kt | 19 ++-- .../domain/model/FacetSelectionsTest.kt | 1 - .../AvailableFacetValuesUseCaseTest.kt | 16 +-- .../domain/usecase/FilterUseCaseTest.kt | 101 ++++++++++-------- .../presentation/mapper/FilterMapperTest.kt | 15 ++- 14 files changed, 177 insertions(+), 153 deletions(-) rename feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/{ItemFacets.kt => ItemAttributes.kt} (69%) diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FacetSelections.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FacetSelections.kt index 31c29151b..d866f803e 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FacetSelections.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FacetSelections.kt @@ -7,8 +7,6 @@ value class FacetSelections private constructor( val facets: Set> get() = byFacet.keys - val isEmpty: Boolean get() = byFacet.isEmpty() - // Every write goes through with(), which stores a facet's values only under that facet. @Suppress("UNCHECKED_CAST") operator fun get(facet: FilterFacet): Set = diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterFacet.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterFacet.kt index 20c2e267b..4427ee214 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterFacet.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterFacet.kt @@ -2,12 +2,40 @@ package de.davis.keygo.feature.list_screen.domain.model import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag +import de.davis.keygo.core.item.domain.model.lite.LiteItem import de.davis.keygo.core.item.generated.domain.model.VaultItemType sealed interface FilterFacet { - data object ItemTypes : FilterFacet - data object Tags : FilterFacet - data object PasswordScores : FilterFacet - data object Pinned : FilterFacet + // An item of a type the facet does not apply to passes its filter and offers none of its values. + fun appliesTo(type: VaultItemType): Boolean = true + + fun valuesFor(item: LiteItem, attributes: ItemAttributes): Set + + data object ItemTypes : FilterFacet { + override fun valuesFor(item: LiteItem, attributes: ItemAttributes) = setOf(item.itemType) + } + + data object Tags : FilterFacet { + override fun valuesFor(item: LiteItem, attributes: ItemAttributes) = + attributes.tagsByItem[item.id].orEmpty() + } + + data object PasswordScores : FilterFacet { + override fun appliesTo(type: VaultItemType) = type == VaultItemType.Login + + override fun valuesFor(item: LiteItem, attributes: ItemAttributes) = + setOfNotNull(attributes.passwordScoreByItem[item.id]) + } + + data object Pinned : FilterFacet { + override fun valuesFor(item: LiteItem, attributes: ItemAttributes) = + if (item.pinned) setOf(true) else emptySet() + } + + companion object { + // A getter, not a stored list: the interface initializes while its objects still are, so an + // eager list would capture a null. + val entries: List> get() = listOf(ItemTypes, Tags, PasswordScores, Pinned) + } } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterState.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterState.kt index dcb8853d4..f41942e11 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterState.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterState.kt @@ -8,8 +8,6 @@ data class FilterState( val isDefault: Boolean get() = this == Default - operator fun get(facet: FilterFacet): Set = selections[facet] - fun with(facet: FilterFacet, values: Set): FilterState = copy(selections = selections.with(facet, values)) diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemFacets.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemAttributes.kt similarity index 69% rename from feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemFacets.kt rename to feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemAttributes.kt index f45b6a26d..8af009373 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemFacets.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemAttributes.kt @@ -4,11 +4,11 @@ import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag -data class ItemFacets( - val passwordScores: Map = emptyMap(), +data class ItemAttributes( + val passwordScoreByItem: Map = emptyMap(), val tagsByItem: Map> = emptyMap(), ) { companion object { - val None = ItemFacets() + val None = ItemAttributes() } } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCase.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCase.kt index ff5350c4e..a39fb5e10 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCase.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCase.kt @@ -1,33 +1,26 @@ package de.davis.keygo.feature.list_screen.domain.usecase import de.davis.keygo.core.item.domain.model.lite.LiteItem -import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.feature.list_screen.domain.model.FacetSelections import de.davis.keygo.feature.list_screen.domain.model.FilterFacet -import de.davis.keygo.feature.list_screen.domain.model.ItemFacets +import de.davis.keygo.feature.list_screen.domain.model.ItemAttributes import org.koin.core.annotation.Single @Single class AvailableFacetValuesUseCase { - operator fun invoke(items: List, facets: ItemFacets): FacetSelections { - val itemTypes = items.mapTo(mutableSetOf()) { it.itemType } - - val tags = buildSet { - items.forEach { item -> facets.tagsByItem[item.id]?.let(::addAll) } + operator fun invoke(items: List, attributes: ItemAttributes): FacetSelections = + FilterFacet.entries.fold(FacetSelections.None) { available, facet -> + available.withValuesOf(facet, items, attributes) } - // Only logins carry a password, so a score keyed to any other item type must not surface. - val loginIds = items.filter { it.itemType == VaultItemType.Login } - .mapTo(mutableSetOf()) { it.id } - val scores = facets.passwordScores.filterKeys { it in loginIds }.values.toSet() - - val pinned = if (items.any { it.pinned }) setOf(true) else emptySet() - - return FacetSelections.None - .with(FilterFacet.ItemTypes, itemTypes) - .with(FilterFacet.Tags, tags) - .with(FilterFacet.PasswordScores, scores) - .with(FilterFacet.Pinned, pinned) - } + private fun FacetSelections.withValuesOf( + facet: FilterFacet, + items: List, + attributes: ItemAttributes, + ): FacetSelections = with( + facet, + items.filter { facet.appliesTo(it.itemType) } + .flatMapTo(mutableSetOf()) { facet.valuesFor(it, attributes) }, + ) } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt index 3a1704594..df7254f11 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt @@ -1,12 +1,11 @@ package de.davis.keygo.feature.list_screen.domain.usecase import de.davis.keygo.core.item.domain.model.lite.LiteItem -import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.list_screen.domain.model.FacetSelections import de.davis.keygo.feature.list_screen.domain.model.FilterFacet import de.davis.keygo.feature.list_screen.domain.model.FilterState -import de.davis.keygo.feature.list_screen.domain.model.ItemFacets +import de.davis.keygo.feature.list_screen.domain.model.ItemAttributes import de.davis.keygo.feature.list_screen.domain.model.SortDirection import org.koin.core.annotation.Single @@ -18,13 +17,13 @@ class FilterUseCase( operator fun invoke( filterState: FilterState, items: List, - facets: ItemFacets, + attributes: ItemAttributes, ): List { val selections = filterState.selections val filtered = items.filter { item -> - selections.facets.all { facet -> - item.matches(facet, selections, facets) - } + selections.facets + .filter { it.appliesTo(item.itemType) } + .all { facet -> facet.matches(item, selections, attributes) } } val (pinned, unpinned) = filtered.partition { it.pinned } @@ -32,20 +31,11 @@ class FilterUseCase( sort(filterState.sortDirection, unpinned) } - private fun LiteItem.matches( - facet: FilterFacet<*>, + private fun FilterFacet.matches( + item: LiteItem, selections: FacetSelections, - facets: ItemFacets, - ): Boolean = when (facet) { - FilterFacet.ItemTypes -> itemType in selections[FilterFacet.ItemTypes] - FilterFacet.Tags -> facets.tagsByItem[id].orEmpty() - .any { it in selections[FilterFacet.Tags] } - - FilterFacet.Pinned -> pinned in selections[FilterFacet.Pinned] - // Only logins carry a password, so every other item passes through. - FilterFacet.PasswordScores -> itemType != VaultItemType.Login || - facets.passwordScores[id]?.let { it in selections[FilterFacet.PasswordScores] } == true - } + attributes: ItemAttributes, + ): Boolean = valuesFor(item, attributes).any { it in selections[this] } private fun sort(direction: SortDirection, items: List): List = sortUseCase(items, ascending = direction == SortDirection.Ascending) { it.name } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCase.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCase.kt index 9fc6d405e..00e62bfbf 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCase.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCase.kt @@ -5,7 +5,7 @@ import de.davis.keygo.core.item.domain.repository.ItemRepository import de.davis.keygo.core.item.domain.repository.LoginRepository import de.davis.keygo.feature.list_screen.domain.model.FilterResult import de.davis.keygo.feature.list_screen.domain.model.FilterState -import de.davis.keygo.feature.list_screen.domain.model.ItemFacets +import de.davis.keygo.feature.list_screen.domain.model.ItemAttributes import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -23,16 +23,16 @@ class ObserveFilterResultUseCase( items: Flow>, filterState: Flow, ): Flow> { - val facets = combine( + val attributes = combine( loginRepository.observePasswordScores(), itemRepository.observeTagsByItem(), - ::ItemFacets, + ::ItemAttributes, ) - return combine(items, filterState, facets) { items, filter, facets -> - val filtered = filterUseCase(filter, items, facets) + return combine(items, filterState, attributes) { items, filter, attributes -> + val filtered = filterUseCase(filter, items, attributes) FilterResult( items = filtered, - available = availableFacetValues(items, facets), + available = availableFacetValues(items, attributes), isEmptyBecauseOfFilter = filtered.isEmpty() && items.isNotEmpty(), ) }.distinctUntilChanged() diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt index 62c2a94ad..dcfe4efac 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt @@ -191,8 +191,10 @@ private fun ItemSection( facet = FilterFacet.ItemTypes, chips = state.itemTypeChips, onAction = onAction, - label = { it.presentation.first }, - icon = { it.presentation.second }, + label = { Text(text = it.presentation.first) }, + leadingIcon = { + Icon(imageVector = it.presentation.second, contentDescription = null) + }, ) } } @@ -208,7 +210,7 @@ private fun ItemSection( facet = FilterFacet.Tags, chips = state.tagChips, onAction = onAction, - label = { it.display }, + label = { Text(text = it.display) }, ) } } @@ -283,7 +285,7 @@ private fun PasswordSection( facet = FilterFacet.PasswordScores, chips = state.passwordScoreChips, onAction = onAction, - label = { it.label() }, + label = { Text(text = it.label()) }, ) } } @@ -294,18 +296,16 @@ private fun FacetChips( facet: FilterFacet, chips: List>, onAction: (FilterAction) -> Unit, - label: @Composable (T) -> String, - icon: (@Composable (T) -> ImageVector)? = null, + label: @Composable (T) -> Unit, + leadingIcon: (@Composable (T) -> Unit)? = null, ) { FlowRow(horizontalArrangement = DefaultHorizontalArrangement) { chips.forEach { chip -> FilterChip( selected = chip.selected, onClick = { onAction(FilterAction.Toggled(facet, chip.value)) }, - label = { Text(text = label(chip.value)) }, - leadingIcon = icon?.let { - { Icon(imageVector = it(chip.value), contentDescription = null) } - }, + label = { label(chip.value) }, + leadingIcon = leadingIcon?.let { { it(chip.value) } }, ) } } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt index 66b6b9515..bcb4e8750 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt @@ -1,5 +1,6 @@ package de.davis.keygo.feature.list_screen.presentation.mapper +import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.feature.list_screen.domain.model.FacetSelections @@ -14,8 +15,8 @@ import de.davis.keygo.feature.list_screen.presentation.model.PasswordSectionStat internal fun FilterState.toBottomSheetState( available: AvailableFilterOptions, restrictedItemType: VaultItemType?, - retained: FacetSelections = FacetSelections.None, - isVisible: Boolean = false, + retained: FacetSelections, + isVisible: Boolean, ): FilterBottomSheetState { val itemTypeChips = available.itemTypes.chips(selections, retained) // A single type offers no choice, unless the user has already picked it. @@ -25,9 +26,12 @@ internal fun FilterState.toBottomSheetState( val tagChips = available.tags.chips(selections, retained) val scoreChips = available.passwordScores.chips(selections, retained) - val effectiveItemTypes = restrictedItemType?.let { setOf(it) } ?: this[FilterFacet.ItemTypes] - val showLoginSection = scoreChips.isNotEmpty() && - (effectiveItemTypes.isEmpty() || VaultItemType.Login in effectiveItemTypes) + val effectiveItemTypes = + restrictedItemType?.let { setOf(it) } ?: selections[FilterFacet.ItemTypes] + val showLoginSection = scoreChips.isNotEmpty() && ( + effectiveItemTypes.isEmpty() || + effectiveItemTypes.any(FilterFacet.PasswordScores::appliesTo) + ) val itemSection = ItemSectionState( onlyPinned = onlyPinned, @@ -46,17 +50,16 @@ internal fun FilterState.toBottomSheetState( ) } -internal fun FacetSelections.toAvailableFilterOptions(allTags: List): AvailableFilterOptions { - val defaults = AvailableFilterOptions() +internal fun FacetSelections.toAvailableFilterOptions(allTags: List) = AvailableFilterOptions( + itemTypes = options(FilterFacet.ItemTypes, VaultItemType.entries), + tags = FacetOptions( + facet = FilterFacet.Tags, + order = allTags, + available = allTags.filterTo(mutableSetOf()) { it in this[FilterFacet.Tags] }, + ), + passwordScores = options(FilterFacet.PasswordScores, PasswordScore.entries.reversed()), + pinned = options(FilterFacet.Pinned, listOf(true)), +) - return AvailableFilterOptions( - itemTypes = defaults.itemTypes.copy(available = this[FilterFacet.ItemTypes]), - tags = FacetOptions( - facet = FilterFacet.Tags, - order = allTags, - available = allTags.filterTo(mutableSetOf()) { it in this[FilterFacet.Tags] }, - ), - passwordScores = defaults.passwordScores.copy(available = this[FilterFacet.PasswordScores]), - pinned = defaults.pinned.copy(available = this[FilterFacet.Pinned]), - ) -} +private fun FacetSelections.options(facet: FilterFacet, order: List) = + FacetOptions(facet, order, available = this[facet]) diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt index 3386c798d..46aba30e7 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt @@ -9,29 +9,24 @@ import de.davis.keygo.feature.list_screen.domain.model.FilterFacet @Immutable internal data class AvailableFilterOptions( - val itemTypes: FacetOptions = - FacetOptions(FilterFacet.ItemTypes, VaultItemType.entries), - val tags: FacetOptions = FacetOptions(FilterFacet.Tags, emptyList()), - val passwordScores: FacetOptions = - FacetOptions(FilterFacet.PasswordScores, PasswordScore.entries.reversed()), - val pinned: FacetOptions = FacetOptions(FilterFacet.Pinned, listOf(true)), + val itemTypes: FacetOptions, + val tags: FacetOptions, + val passwordScores: FacetOptions, + val pinned: FacetOptions, ) @Immutable internal data class FacetOptions( val facet: FilterFacet, val order: List, - val available: Set = emptySet(), + val available: Set, ) { - fun chips( - selections: FacetSelections, - retained: FacetSelections = FacetSelections.None, - ): List> { + fun chips(selections: FacetSelections, retained: FacetSelections): List> { val selected = selections[facet] val shown = available + selected + retained[facet] return (order + shown).distinct() - .filter { it in shown } + .filter { it in shown } // drops the ordered values that shouldn't be shown .map { FilterChipState(value = it, selected = it in selected) } } } diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/model/FacetSelectionsTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/model/FacetSelectionsTest.kt index 415a015e3..911e9070d 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/model/FacetSelectionsTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/model/FacetSelectionsTest.kt @@ -20,7 +20,6 @@ class FacetSelectionsTest { .toggle(FilterFacet.PasswordScores, PasswordScore.Weak) assertEquals(FacetSelections.None, toggled) - assertTrue(toggled.isEmpty) } @Test diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCaseTest.kt index b8e96ed32..05a06c8ad 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCaseTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCaseTest.kt @@ -6,7 +6,7 @@ import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.model.lite.LiteItem import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.feature.list_screen.domain.model.FilterFacet -import de.davis.keygo.feature.list_screen.domain.model.ItemFacets +import de.davis.keygo.feature.list_screen.domain.model.ItemAttributes import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals @@ -32,7 +32,7 @@ class AvailableFacetValuesUseCaseTest { TestLiteItem("Login"), TestLiteItem("Card", itemType = VaultItemType.CreditCard), ), - facets = ItemFacets.None, + attributes = ItemAttributes.None, ) assertEquals( @@ -48,7 +48,7 @@ class AvailableFacetValuesUseCaseTest { val result = useCase( items = listOf(a, b), - facets = ItemFacets( + attributes = ItemAttributes( tagsByItem = mapOf( a.id to setOf(tag("Bank")), b.id to setOf(tag("Work")), @@ -66,7 +66,7 @@ class AvailableFacetValuesUseCaseTest { val result = useCase( items = listOf(a), - facets = ItemFacets( + attributes = ItemAttributes( tagsByItem = mapOf( a.id to setOf(tag("Bank")), hidden.id to setOf(tag("Secret")), @@ -79,7 +79,7 @@ class AvailableFacetValuesUseCaseTest { @Test fun `no tags, scores, or pinned flag when nothing carries them`() { - val result = useCase(listOf(TestLiteItem("A")), ItemFacets.None) + val result = useCase(listOf(TestLiteItem("A")), ItemAttributes.None) assertTrue(result[FilterFacet.Tags].isEmpty()) assertTrue(result[FilterFacet.PasswordScores].isEmpty()) @@ -93,8 +93,8 @@ class AvailableFacetValuesUseCaseTest { val result = useCase( items = listOf(login, card), - facets = ItemFacets( - passwordScores = mapOf( + attributes = ItemAttributes( + passwordScoreByItem = mapOf( login.id to PasswordScore.Strong, // A score keyed to a non-login id should never happen in practice, but must not // leak into the available set if it does. @@ -110,7 +110,7 @@ class AvailableFacetValuesUseCaseTest { fun `pinned is offered when any item is pinned`() { val result = useCase( items = listOf(TestLiteItem("A", pinned = true), TestLiteItem("B")), - facets = ItemFacets.None, + attributes = ItemAttributes.None, ) assertEquals(setOf(true), result[FilterFacet.Pinned]) diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt index 9ea3eb893..ec234f829 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt @@ -9,7 +9,7 @@ import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.list_screen.domain.model.FilterFacet import de.davis.keygo.feature.list_screen.domain.model.FilterState -import de.davis.keygo.feature.list_screen.domain.model.ItemFacets +import de.davis.keygo.feature.list_screen.domain.model.ItemAttributes import de.davis.keygo.feature.list_screen.domain.model.SortDirection import java.util.UUID import kotlin.test.Test @@ -37,7 +37,7 @@ class FilterUseCaseTest { fun ascProduces(vararg expected: String) { val provided = items(*items.toTypedArray()) val expected = items(*expected) - val result = useCase(filterStateAsc, provided, ItemFacets.None) + val result = useCase(filterStateAsc, provided, ItemAttributes.None) assertEquals(expected, result) } @@ -45,7 +45,7 @@ class FilterUseCaseTest { fun descProduces(vararg expected: String) { val provided = items(*items.toTypedArray()) val expected = items(*expected) - val result = useCase(filterStateDesc, provided, ItemFacets.None) + val result = useCase(filterStateDesc, provided, ItemAttributes.None) assertEquals(expected, result) } @@ -74,7 +74,7 @@ class FilterUseCaseTest { fun `filtering an empty list returns empty list`() { val state = FilterState() .with(FilterFacet.PasswordScores, setOf(PasswordScore.Weak)) - val result = useCase(state, emptyList(), noFacets) + val result = useCase(state, emptyList(), noAttributes) assertTrue(result.isEmpty()) } @@ -83,7 +83,7 @@ class FilterUseCaseTest { val result = useCase( filterStateAsc, items("cherry", "Banana", "apple", "Apple", "banana"), - ItemFacets.None + ItemAttributes.None ) // At Collator.PRIMARY, Apple/apple are equal and banana/Banana are equal, so @@ -110,7 +110,7 @@ class FilterUseCaseTest { @Test fun `identical names are stable - order preserved`() { - val result = useCase(filterStateAsc, items("same", "same", "same"), ItemFacets.None) + val result = useCase(filterStateAsc, items("same", "same", "same"), ItemAttributes.None) assertEquals(3, result.size) assertTrue(result.all { it.name == "same" }) } @@ -119,7 +119,7 @@ class FilterUseCaseTest { @Test fun `names starting with special characters sort before letters`() { val result = - useCase(filterStateAsc, items("banana", "apple", "/path", "&tag"), ItemFacets.None) + useCase(filterStateAsc, items("banana", "apple", "/path", "&tag"), ItemAttributes.None) val lastSpecialIndex = maxOf( result.indexOfFirst { it.name == "/path" }, result.indexOfFirst { it.name == "&tag" } @@ -134,15 +134,15 @@ class FilterUseCaseTest { @Test fun `special characters mixed with numbers produce deterministic order`() { val input = items("&2item", "/1item", "#3item") - val result1 = useCase(filterStateAsc, input, ItemFacets.None) - val result2 = useCase(filterStateAsc, input, ItemFacets.None) + val result1 = useCase(filterStateAsc, input, ItemAttributes.None) + val result2 = useCase(filterStateAsc, input, ItemAttributes.None) assertEquals(result1, result2) } @Test fun `backslash and forward slash in names returns all items`() { val input = items("aab", "a/b", "a&b", "a\\b") - val result = useCase(filterStateAsc, input, ItemFacets.None) + val result = useCase(filterStateAsc, input, ItemAttributes.None) assertEquals(4, result.size) assertTrue(result.map { it.name }.containsAll(input.map { it.name })) } @@ -150,8 +150,8 @@ class FilterUseCaseTest { @Test fun `names with only special characters - descending is reverse of ascending`() { val input = items("///", "&&&", "\\\\", "###") - val asc = useCase(filterStateAsc, input, ItemFacets.None) - val desc = useCase(filterStateDesc, input, ItemFacets.None) + val asc = useCase(filterStateAsc, input, ItemAttributes.None) + val desc = useCase(filterStateDesc, input, ItemAttributes.None) assertEquals(asc.reversed(), desc) } @@ -189,14 +189,18 @@ class FilterUseCaseTest { @Test fun `blank and empty names do not crash`() { - assertEquals(3, useCase(filterStateAsc, items("normal", " ", ""), ItemFacets.None).size) + assertEquals(3, useCase(filterStateAsc, items("normal", " ", ""), ItemAttributes.None).size) } // Edge: case sensitivity @Test fun `uppercase and lowercase letters with same prefix are grouped before later letters`() { val result = - useCase(filterStateAsc, items("Banana", "banana", "apple", "Apple"), ItemFacets.None) + useCase( + filterStateAsc, + items("Banana", "banana", "apple", "Apple"), + ItemAttributes.None + ) val lastAppleIndex = maxOf( result.indexOfFirst { it.name == "apple" }, result.indexOfFirst { it.name == "Apple" } @@ -216,8 +220,8 @@ class FilterUseCaseTest { @Test fun `descending is exact reverse of ascending for unique names`() { val input = items("file2", "file10", "file1", "abc", "10abc") - val asc = useCase(filterStateAsc, input, ItemFacets.None) - val desc = useCase(filterStateDesc, input, ItemFacets.None) + val asc = useCase(filterStateAsc, input, ItemAttributes.None) + val desc = useCase(filterStateDesc, input, ItemAttributes.None) assertEquals(asc.reversed(), desc) } @@ -225,8 +229,8 @@ class FilterUseCaseTest { @Test fun `fully mixed names produce deterministic order`() { val input = items("10", "abc", "/path", "2file", "FILE3", "&special", "1") - val result1 = useCase(filterStateAsc, input, ItemFacets.None) - val result2 = useCase(filterStateAsc, input, ItemFacets.None) + val result1 = useCase(filterStateAsc, input, ItemAttributes.None) + val result2 = useCase(filterStateAsc, input, ItemAttributes.None) assertEquals(result1, result2) } @@ -235,7 +239,7 @@ class FilterUseCaseTest { fun `names with emoji do not crash`() { assertEquals( 3, - useCase(filterStateAsc, items("banana", "🍌2", "🍎 apple"), ItemFacets.None).size + useCase(filterStateAsc, items("banana", "🍌2", "🍎 apple"), ItemAttributes.None).size ) } @@ -245,19 +249,19 @@ class FilterUseCaseTest { TestLiteItem(name = "Login B", id = newItemId(), itemType = VaultItemType.Login), ) - private val noFacets = ItemFacets.None + private val noAttributes = ItemAttributes.None @Test fun `no item type selected returns all items`() { val state = FilterState() - val result = useCase(state, typedItems, noFacets) + val result = useCase(state, typedItems, noAttributes) assertEquals(result, typedItems) } @Test fun `selecting Login type keeps only logins`() { val state = FilterState().with(FilterFacet.ItemTypes, setOf(VaultItemType.Login)) - val result = useCase(state, typedItems, noFacets) + val result = useCase(state, typedItems, noAttributes) assertTrue(result.all { it.itemType == VaultItemType.Login }) } @@ -276,13 +280,17 @@ class FilterUseCaseTest { scoredItems[3].id to PasswordScore.Weak, ) - private val scoreFacets = ItemFacets(passwordScores = scores) + private val scoreAttributes = ItemAttributes(passwordScoreByItem = scores) @Test fun `no score selected returns all login items`() { val state = FilterState() val result = - useCase(state, scoredItems, scoreFacets).filter { it.itemType == VaultItemType.Login } + useCase( + state, + scoredItems, + scoreAttributes + ).filter { it.itemType == VaultItemType.Login } val expected = scoredItems.filter { it.itemType == VaultItemType.Login } assertEquals(expected, result) } @@ -291,7 +299,7 @@ class FilterUseCaseTest { fun `selecting single score returns matching items`() { val state = FilterState() .with(FilterFacet.PasswordScores, setOf(PasswordScore.Weak)) - val result = useCase(state, scoredItems, scoreFacets) + val result = useCase(state, scoredItems, scoreAttributes) val expected = scoredItems.filter { scores[it.id] == PasswordScore.Weak } assertEquals(expected, result) @@ -301,7 +309,7 @@ class FilterUseCaseTest { fun `selecting multiple scores returns union of matches`() { val state = FilterState() .with(FilterFacet.PasswordScores, setOf(PasswordScore.Weak, PasswordScore.Excellent)) - val result = useCase(state, scoredItems, scoreFacets) + val result = useCase(state, scoredItems, scoreAttributes) val expected = scoredItems.filter { scores[it.id] == PasswordScore.Weak || @@ -316,7 +324,7 @@ class FilterUseCaseTest { fun `selecting a score not present in any item returns empty list`() { val state = FilterState() .with(FilterFacet.PasswordScores, setOf(PasswordScore.Ridiculous)) - val result = useCase(state, scoredItems, scoreFacets) + val result = useCase(state, scoredItems, scoreAttributes) assertTrue(result.isEmpty()) } @@ -326,7 +334,7 @@ class FilterUseCaseTest { val state = FilterState() .with(FilterFacet.ItemTypes, setOf(VaultItemType.Login)) .with(FilterFacet.PasswordScores, setOf(PasswordScore.Excellent)) - val result = useCase(state, scoredItems, scoreFacets) + val result = useCase(state, scoredItems, scoreAttributes) assertTrue(result.all { it.itemType == VaultItemType.Login }) assertTrue(result.all { scores[it.id] == PasswordScore.Excellent }) } @@ -335,10 +343,10 @@ class FilterUseCaseTest { fun `filter result is still sorted by sort direction`() { val state = FilterState(sortDirection = SortDirection.Descending) .with(FilterFacet.PasswordScores, setOf(PasswordScore.Weak, PasswordScore.Strong)) - val result = useCase(state, scoredItems, scoreFacets) + val result = useCase(state, scoredItems, scoreAttributes) val ascState = state.copy(sortDirection = SortDirection.Ascending) - val ascResult = useCase(ascState, scoredItems, scoreFacets) + val ascResult = useCase(ascState, scoredItems, scoreAttributes) assertEquals(ascResult.reversed(), result) } @@ -353,7 +361,7 @@ class FilterUseCaseTest { @Test fun `pinned items appear before unpinned items`() { - val result = useCase(filterStateAsc, mixedPinnedItems, noFacets) + val result = useCase(filterStateAsc, mixedPinnedItems, noAttributes) val pinnedNames = result.takeWhile { it.pinned }.map { it.name } val unpinnedNames = result.dropWhile { it.pinned }.map { it.name } @@ -364,7 +372,7 @@ class FilterUseCaseTest { @Test fun `pinned items appear before unpinned items in descending order`() { - val result = useCase(filterStateDesc, mixedPinnedItems, noFacets) + val result = useCase(filterStateDesc, mixedPinnedItems, noAttributes) val pinnedNames = result.takeWhile { it.pinned }.map { it.name } val unpinnedNames = result.dropWhile { it.pinned }.map { it.name } @@ -376,7 +384,7 @@ class FilterUseCaseTest { @Test fun `onlyPinned filter excludes unpinned items`() { val state = FilterState().with(FilterFacet.Pinned, setOf(true)) - val result = useCase(state, mixedPinnedItems, noFacets) + val result = useCase(state, mixedPinnedItems, noAttributes) assertTrue(result.all { it.pinned }) assertEquals(2, result.size) @@ -385,7 +393,7 @@ class FilterUseCaseTest { @Test fun `onlyPinned false returns all items`() { val state = FilterState() - val result = useCase(state, mixedPinnedItems, noFacets) + val result = useCase(state, mixedPinnedItems, noAttributes) assertEquals(4, result.size) } @@ -394,7 +402,7 @@ class FilterUseCaseTest { fun `onlyPinned with no pinned items returns empty list`() { val state = FilterState().with(FilterFacet.Pinned, setOf(true)) val unpinnedOnly = items("A", "B", "C") - val result = useCase(state, unpinnedOnly, noFacets) + val result = useCase(state, unpinnedOnly, noAttributes) assertTrue(result.isEmpty()) } @@ -404,7 +412,7 @@ class FilterUseCaseTest { val state = FilterState() .with(FilterFacet.Pinned, setOf(true)) .with(FilterFacet.ItemTypes, setOf(VaultItemType.Login)) - val result = useCase(state, mixedPinnedItems, noFacets) + val result = useCase(state, mixedPinnedItems, noAttributes) assertTrue(result.all { it.pinned }) assertTrue(result.all { it.itemType == VaultItemType.Login }) @@ -417,7 +425,7 @@ class FilterUseCaseTest { TestLiteItem(name = "Alpha", pinned = true), TestLiteItem(name = "Mike", pinned = true), ) - val result = useCase(filterStateAsc, allPinned, noFacets) + val result = useCase(filterStateAsc, allPinned, noAttributes) assertEquals(listOf("Alpha", "Mike", "Zulu"), result.map { it.name }) } @@ -433,14 +441,14 @@ class FilterUseCaseTest { @Test fun `an item carrying the selected tag passes while others are dropped`() { - val facets = ItemFacets( + val attributes = ItemAttributes( tagsByItem = mapOf( tagItems[0].id to setOf(Tag.of("Bank")!!), // tagItems[1] carries no tags entry at all. tagItems[2].id to setOf(Tag.of("Work")!!), ), ) - val result = useCase(bankTagged, tagItems, facets) + val result = useCase(bankTagged, tagItems, attributes) assertEquals(listOf(tagItems[0].id), result.map { it.id }) } @@ -449,14 +457,14 @@ class FilterUseCaseTest { fun `two selected tags keep items carrying either`() { val state = FilterState() .with(FilterFacet.Tags, setOf(Tag.of("Bank")!!, Tag.of("Work")!!)) - val facets = ItemFacets( + val attributes = ItemAttributes( tagsByItem = mapOf( tagItems[0].id to setOf(Tag.of("Bank")!!), tagItems[1].id to setOf(Tag.of("Personal")!!), tagItems[2].id to setOf(Tag.of("Work")!!), ), ) - val result = useCase(state, tagItems, facets) + val result = useCase(state, tagItems, attributes) assertEquals(setOf(tagItems[0].id, tagItems[2].id), result.mapTo(mutableSetOf()) { it.id }) } @@ -464,8 +472,9 @@ class FilterUseCaseTest { @Test fun `a selected tag matches an item tagged with different casing`() { val state = FilterState().with(FilterFacet.Tags, setOf(Tag.of("bank")!!)) - val facets = ItemFacets(tagsByItem = mapOf(tagItems[0].id to setOf(Tag.of("Bank")!!))) - val result = useCase(state, tagItems, facets) + val attributes = + ItemAttributes(tagsByItem = mapOf(tagItems[0].id to setOf(Tag.of("Bank")!!))) + val result = useCase(state, tagItems, attributes) assertEquals(listOf(tagItems[0].id), result.map { it.id }) } @@ -475,20 +484,20 @@ class FilterUseCaseTest { val loginBank = TestLiteItem(name = "Login Bank", itemType = VaultItemType.Login) val cardBank = TestLiteItem(name = "Card Bank", itemType = VaultItemType.CreditCard) val state = bankTagged.with(FilterFacet.ItemTypes, setOf(VaultItemType.Login)) - val facets = ItemFacets( + val attributes = ItemAttributes( tagsByItem = mapOf( loginBank.id to setOf(Tag.of("Bank")!!), cardBank.id to setOf(Tag.of("Bank")!!), ), ) - val result = useCase(state, listOf(loginBank, cardBank), facets) + val result = useCase(state, listOf(loginBank, cardBank), attributes) assertEquals(listOf(loginBank.id), result.map { it.id }) } @Test fun `no Tags selection keeps untagged items`() { - val result = useCase(FilterState(), tagItems, noFacets) + val result = useCase(FilterState(), tagItems, noAttributes) assertEquals(tagItems.size, result.size) assertTrue(result.containsAll(tagItems)) diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapperTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapperTest.kt index 29e048a71..6a7ac464d 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapperTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapperTest.kt @@ -30,6 +30,7 @@ class FilterMapperTest { available = facets.toAvailableFilterOptions(allTags = emptyList()), restrictedItemType = null, retained = retained, + isVisible = false, ) private fun scoresAvailable(vararg scores: PasswordScore): FacetSelections = @@ -55,7 +56,12 @@ class FilterMapperTest { .toAvailableFilterOptions(allTags = listOf(tag("Bank"), tag("Work"))) val sheet = FilterState().with(FilterFacet.Tags, setOf(tag("Bank"))) - .toBottomSheetState(available, restrictedItemType = null) + .toBottomSheetState( + available, + restrictedItemType = null, + retained = FacetSelections.None, + isVisible = false, + ) val chips = sheet.itemSection?.tagChips.orEmpty() assertEquals(setOf(tag("Bank"), tag("Work")), chips.map { it.value }.toSet()) @@ -164,7 +170,12 @@ class FilterMapperTest { .toAvailableFilterOptions(allTags = listOf(tag("Work"))) val sheet = FilterState().with(FilterFacet.Tags, setOf(tag("Bank"))) - .toBottomSheetState(available, restrictedItemType = null) + .toBottomSheetState( + available, + restrictedItemType = null, + retained = FacetSelections.None, + isVisible = false, + ) assertEquals( listOf( From 979906f2bd262f81085ad7a0fb248c317b1c01eb Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Wed, 30 Sep 2026 01:01:24 +0200 Subject: [PATCH 7/9] feat: implement by credential filter --- .../core/item/data/local/dao/LoginDao.kt | 12 ++ .../local/pojo/LoginCredentialsProjection.kt | 14 ++ .../core/item/data/mapper/LoginMapper.kt | 8 + .../data/repository/LoginRepositoryImpl.kt | 7 + .../core/item/domain/model/CredentialType.kt | 3 + .../item/domain/repository/LoginRepository.kt | 3 + .../core/item/data/local/dao/LoginDaoTest.kt | 141 ++++++++++++++++++ .../keygo/core/item/FakeLoginRepository.kt | 12 ++ .../list_screen/domain/model/FilterFacet.kt | 11 +- .../domain/model/ItemAttributes.kt | 2 + .../usecase/ObserveFilterResultUseCase.kt | 1 + .../components/FilterBottomSheet.kt | 77 +++++++--- .../components/ItemListContent.kt | 2 +- .../presentation/mapper/FilterMapper.kt | 23 ++- .../model/AvailableFilterOptions.kt | 2 + .../model/FilterBottomSheetState.kt | 6 +- .../src/main/res/values/strings.xml | 5 +- .../AvailableFacetValuesUseCaseTest.kt | 27 +++- .../domain/usecase/FilterUseCaseTest.kt | 52 ++++++- .../usecase/ObserveFilterResultUseCaseTest.kt | 26 +++- .../presentation/mapper/FilterMapperTest.kt | 46 +++++- 21 files changed, 445 insertions(+), 35 deletions(-) create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LoginCredentialsProjection.kt create mode 100644 core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CredentialType.kt create mode 100644 core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/LoginDaoTest.kt diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/LoginDao.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/LoginDao.kt index ca0b6e04d..a60016357 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/LoginDao.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/LoginDao.kt @@ -6,6 +6,7 @@ import androidx.room3.Transaction import androidx.room3.Upsert import de.davis.keygo.core.item.data.local.entity.LoginEntity import de.davis.keygo.core.item.data.local.pojo.LightweightLogin +import de.davis.keygo.core.item.data.local.pojo.LoginCredentialsProjection import de.davis.keygo.core.item.data.local.pojo.LoginProjection import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.VaultId @@ -21,6 +22,17 @@ internal interface LoginDao { @Query("SELECT * FROM login") fun observeAll(): Flow> + @Query( + """ + SELECT l.id, + EXISTS (SELECT 1 FROM password p WHERE p.login_id = l.id) AS has_password, + EXISTS (SELECT 1 FROM passkey pk WHERE pk.login_id = l.id) AS has_passkey, + EXISTS (SELECT 1 FROM totp t WHERE t.login_id = l.id) AS has_totp + FROM login l + """ + ) + fun observeCredentials(): Flow> + @Transaction @Query("SELECT * FROM login WHERE id = :id") fun observeById(id: ItemId): Flow diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LoginCredentialsProjection.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LoginCredentialsProjection.kt new file mode 100644 index 000000000..d15dcc3ca --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LoginCredentialsProjection.kt @@ -0,0 +1,14 @@ +package de.davis.keygo.core.item.data.local.pojo + +import androidx.room3.ColumnInfo +import de.davis.keygo.core.item.domain.alias.ItemId + +internal data class LoginCredentialsProjection( + val id: ItemId, + @ColumnInfo(name = "has_password") + val hasPassword: Boolean, + @ColumnInfo(name = "has_passkey") + val hasPasskey: Boolean, + @ColumnInfo(name = "has_totp") + val hasTotp: Boolean, +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapper.kt index 409f1f783..f0ff72bbf 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapper.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/mapper/LoginMapper.kt @@ -5,7 +5,9 @@ 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.credential.PasswordEntity import de.davis.keygo.core.item.data.local.pojo.LightweightLogin +import de.davis.keygo.core.item.data.local.pojo.LoginCredentialsProjection import de.davis.keygo.core.item.data.local.pojo.LoginProjection +import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.PasskeyRef import de.davis.keygo.core.item.domain.model.PasswordCredential @@ -55,3 +57,9 @@ internal fun LightweightLogin.toDomain(): LiteLogin = LiteLogin( hasPassword = hasPassword, domains = domains.map(DomainInfoEntity::toDomain), ) + +internal fun LoginCredentialsProjection.toCredentialTypes(): Set = buildSet { + if (hasPassword) add(CredentialType.Password) + if (hasPasskey) add(CredentialType.Passkey) + if (hasTotp) add(CredentialType.Totp) +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImpl.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImpl.kt index 8b116908c..12ba6bb61 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImpl.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/LoginRepositoryImpl.kt @@ -9,6 +9,7 @@ import de.davis.keygo.core.item.data.local.dao.TagDao import de.davis.keygo.core.item.data.local.dao.TotpDao import de.davis.keygo.core.item.data.local.pojo.LightweightLogin import de.davis.keygo.core.item.data.local.pojo.LoginProjection +import de.davis.keygo.core.item.data.mapper.toCredentialTypes import de.davis.keygo.core.item.data.mapper.toData import de.davis.keygo.core.item.data.mapper.toDomain import de.davis.keygo.core.item.data.mapper.toDomainInfoEntities @@ -17,6 +18,7 @@ import de.davis.keygo.core.item.data.mapper.toPasswordEntity import de.davis.keygo.core.item.data.mapper.toTagEntities import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.Item import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.Passkey @@ -106,4 +108,9 @@ internal class LoginRepositoryImpl( passwordDao.observeScores().map { entries -> entries.associate { it.id to it.passwordScore } } + + override fun observeCredentialTypes(): Flow>> = + loginDao.observeCredentials().map { rows -> + rows.associate { it.id to it.toCredentialTypes() } + } } diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CredentialType.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CredentialType.kt new file mode 100644 index 000000000..653ca0889 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CredentialType.kt @@ -0,0 +1,3 @@ +package de.davis.keygo.core.item.domain.model + +enum class CredentialType { Password, Passkey, Totp } diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/LoginRepository.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/LoginRepository.kt index fe0ab7b69..802abd254 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/LoginRepository.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/LoginRepository.kt @@ -2,6 +2,7 @@ package de.davis.keygo.core.item.domain.repository import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.Passkey import de.davis.keygo.core.item.domain.model.PasswordScore @@ -44,4 +45,6 @@ interface LoginRepository { fun observeLogins(): Flow> fun observePasswordScores(): Flow> + + fun observeCredentialTypes(): Flow>> } diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/LoginDaoTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/LoginDaoTest.kt new file mode 100644 index 000000000..7897a7219 --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/local/dao/LoginDaoTest.kt @@ -0,0 +1,141 @@ +package de.davis.keygo.core.item.data.local.dao + +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.Timestamp +import de.davis.keygo.core.item.data.local.entity.VaultEntity +import de.davis.keygo.core.item.data.local.entity.credential.PasskeyEntity +import de.davis.keygo.core.item.data.local.entity.credential.PasswordEntity +import de.davis.keygo.core.item.data.local.entity.credential.TotpEntity +import de.davis.keygo.core.item.data.local.pojo.LoginCredentialsProjection +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.EncryptedPayload +import de.davis.keygo.core.item.domain.model.PasswordScore +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import de.davis.keygo.core.item.data.local.entity.KeyInformation as EntityKeyInformation + +internal class LoginDaoTest { + + private lateinit var db: ItemDatabase + private lateinit var loginDao: LoginDao + + private val vaultId: VaultId = newVaultId() + private val payload = EncryptedPayload(byteArrayOf(1), byteArrayOf(2)) + + @BeforeTest + fun setUp() = runBlocking { + db = Room.inMemoryDatabaseBuilder(mockk(relaxed = true), ItemDatabase::class.java) + .setDriver(BundledSQLiteDriver()) + .setQueryCoroutineContext(Dispatchers.IO) + .build() + loginDao = db.loginDao() + db.vaultDao().insert( + VaultEntity( + id = vaultId, + name = "Vault", + icon = Vault.Icon.Person, + createdAt = 0L, + keyInformation = EntityKeyInformation(byteArrayOf(), byteArrayOf()), + ) + ) + } + + @AfterTest + fun tearDown() { + db.close() + } + + private suspend fun insertLogin(id: ItemId = newItemId()): ItemId { + db.itemDao().upsert( + ItemEntity( + id = id, + vaultId = vaultId, + name = "Login", + note = null, + itemType = VaultItemType.Login, + pinned = false, + keyInformation = EntityKeyInformation(byteArrayOf(), byteArrayOf()), + timestamp = Timestamp(createdAt = 0L, modifiedAt = null), + ) + ) + loginDao.upsert(LoginEntity(id = id, username = null)) + return id + } + + private suspend fun addPassword(loginId: ItemId) = db.passwordDao().upsert( + PasswordEntity(loginId = loginId, passwordScore = PasswordScore.Strong, password = payload) + ) + + private suspend fun addPasskey(loginId: ItemId, credentialId: Byte) = + db.passkeyDao().insertPasskey( + PasskeyEntity( + credentialId = byteArrayOf(credentialId), + loginId = loginId, + rp = "example.com", + privateKey = payload, + name = "user", + displayName = "User", + ) + ) + + private suspend fun addTotp(loginId: ItemId) = db.totpDao().upsert( + TotpEntity( + loginId = loginId, + secret = payload, + issuer = null, + accountName = null, + algorithm = "SHA1", + digits = 6, + period = 30, + ) + ) + + @Test + fun `observeCredentials reports which credentials each login holds`() = runTest { + val bare = insertLogin() + val passwordOnly = insertLogin().also { addPassword(it) } + val everything = insertLogin().also { + addPassword(it) + addPasskey(it, credentialId = 1) + addPasskey(it, credentialId = 2) + addTotp(it) + } + + val rows = loginDao.observeCredentials().first().associateBy { it.id } + + assertEquals( + mapOf( + bare to LoginCredentialsProjection(bare, false, false, false), + passwordOnly to LoginCredentialsProjection(passwordOnly, true, false, false), + everything to LoginCredentialsProjection(everything, true, true, true), + ), + rows, + ) + } + + @Test + fun `observeCredentials drops a credential once its row is deleted`() = runTest { + val login = insertLogin().also { addTotp(it) } + assertEquals(true, loginDao.observeCredentials().first().single().hasTotp) + + db.totpDao().delete(login) + + assertEquals(false, loginDao.observeCredentials().first().single().hasTotp) + } +} diff --git a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeLoginRepository.kt b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeLoginRepository.kt index e3e8919c7..4e7f3fb5b 100644 --- a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeLoginRepository.kt +++ b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeLoginRepository.kt @@ -2,6 +2,7 @@ package de.davis.keygo.core.item import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.Passkey import de.davis.keygo.core.item.domain.model.PasswordScore @@ -115,4 +116,15 @@ class FakeLoginRepository : LoginRepository { .mapNotNull { login -> login.passwordCredential?.let { login.id to it.score } } .toMap() } + + override fun observeCredentialTypes(): Flow>> = + store.map { logins -> + logins.values.associate { login -> + login.id to buildSet { + if (login.passwordCredential != null) add(CredentialType.Password) + if (login.passkeys.isNotEmpty()) add(CredentialType.Passkey) + if (login.totp != null) add(CredentialType.Totp) + } + } + } } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterFacet.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterFacet.kt index 4427ee214..566f13099 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterFacet.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterFacet.kt @@ -1,5 +1,6 @@ package de.davis.keygo.feature.list_screen.domain.model +import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.model.lite.LiteItem @@ -28,6 +29,13 @@ sealed interface FilterFacet { setOfNotNull(attributes.passwordScoreByItem[item.id]) } + data object Credentials : FilterFacet { + override fun appliesTo(type: VaultItemType) = type == VaultItemType.Login + + override fun valuesFor(item: LiteItem, attributes: ItemAttributes) = + attributes.credentialsByItem[item.id].orEmpty() + } + data object Pinned : FilterFacet { override fun valuesFor(item: LiteItem, attributes: ItemAttributes) = if (item.pinned) setOf(true) else emptySet() @@ -36,6 +44,7 @@ sealed interface FilterFacet { companion object { // A getter, not a stored list: the interface initializes while its objects still are, so an // eager list would capture a null. - val entries: List> get() = listOf(ItemTypes, Tags, PasswordScores, Pinned) + val entries: List> + get() = listOf(ItemTypes, Tags, PasswordScores, Credentials, Pinned) } } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemAttributes.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemAttributes.kt index 8af009373..b198b889f 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemAttributes.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemAttributes.kt @@ -1,12 +1,14 @@ package de.davis.keygo.feature.list_screen.domain.model import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag data class ItemAttributes( val passwordScoreByItem: Map = emptyMap(), val tagsByItem: Map> = emptyMap(), + val credentialsByItem: Map> = emptyMap(), ) { companion object { val None = ItemAttributes() diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCase.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCase.kt index 00e62bfbf..ac71d8c30 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCase.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCase.kt @@ -26,6 +26,7 @@ class ObserveFilterResultUseCase( val attributes = combine( loginRepository.observePasswordScores(), itemRepository.observeTagsByItem(), + loginRepository.observeCredentialTypes(), ::ItemAttributes, ) return combine(items, filterState, attributes) { items, filter, attributes -> diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt index dcfe4efac..357969d71 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt @@ -14,7 +14,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Category +import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Password +import androidx.compose.material.icons.filled.Timer import androidx.compose.material3.ButtonGroupDefaults import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -43,6 +45,7 @@ import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.generated.domain.model.VaultItemType @@ -58,7 +61,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.FilterChipState import de.davis.keygo.feature.list_screen.presentation.model.ItemSectionState -import de.davis.keygo.feature.list_screen.presentation.model.PasswordSectionState +import de.davis.keygo.feature.list_screen.presentation.model.LoginSectionState import de.davis.keygo.core.item.R as CoreItemR @OptIn(ExperimentalMaterial3Api::class) @@ -131,10 +134,10 @@ private fun FilterBottomSheetContent( } } - if (state.passwordSection != null) { - item(key = "passwords") { - PasswordSection( - state = state.passwordSection, + if (state.loginSection != null) { + item(key = "logins") { + LoginSection( + state = state.loginSection, onAction = onAction, modifier = Modifier.animateItem(), ) @@ -262,8 +265,8 @@ private fun SortSection( } @Composable -private fun PasswordSection( - state: PasswordSectionState, +private fun LoginSection( + state: LoginSectionState, onAction: (FilterAction) -> Unit, modifier: Modifier = Modifier, ) { @@ -276,17 +279,37 @@ private fun PasswordSection( title = stringResource(CoreItemR.string.login), ) - KeyGoCard( - title = { - Text(text = stringResource(R.string.password_strength)) - }, - ) { - FacetChips( - facet = FilterFacet.PasswordScores, - chips = state.passwordScoreChips, - onAction = onAction, - label = { Text(text = it.label()) }, - ) + if (state.passwordScoreChips.isNotEmpty()) { + KeyGoCard( + title = { + Text(text = stringResource(R.string.password_strength)) + }, + ) { + FacetChips( + facet = FilterFacet.PasswordScores, + chips = state.passwordScoreChips, + onAction = onAction, + label = { Text(text = it.label()) }, + ) + } + } + + if (state.credentialChips.isNotEmpty()) { + KeyGoCard( + title = { + Text(text = stringResource(R.string.credentials)) + }, + ) { + FacetChips( + facet = FilterFacet.Credentials, + chips = state.credentialChips, + onAction = onAction, + label = { Text(text = it.label()) }, + leadingIcon = { + Icon(imageVector = it.icon(), contentDescription = null) + }, + ) + } } } } @@ -343,6 +366,19 @@ private fun PasswordScore.label(): String = when (this) { PasswordScore.Excellent -> stringResource(CoreItemR.string.password_strength_excellent) } +@Composable +private fun CredentialType.label(): String = when (this) { + CredentialType.Password -> stringResource(CoreItemR.string.password) + CredentialType.Passkey -> stringResource(R.string.credential_passkey) + CredentialType.Totp -> stringResource(R.string.credential_totp) +} + +private fun CredentialType.icon(): ImageVector = when (this) { + CredentialType.Password -> Icons.Default.Password + CredentialType.Passkey -> Icons.Default.Key + CredentialType.Totp -> Icons.Default.Timer +} + @Composable private fun SortDirection.label(): String = when (this) { SortDirection.Ascending -> stringResource(R.string.ascending) @@ -376,7 +412,7 @@ private fun FilterBottomSheetContentPreview() { FilterChipState(value = Tag.of("Label2")!!, selected = true), ), ), - passwordSection = PasswordSectionState( + loginSection = LoginSectionState( passwordScoreChips = listOf( FilterChipState(value = PasswordScore.Excellent, selected = false), FilterChipState(value = PasswordScore.Strong, selected = false), @@ -384,6 +420,9 @@ private fun FilterBottomSheetContentPreview() { FilterChipState(value = PasswordScore.Weak, selected = true), FilterChipState(value = PasswordScore.Ridiculous, selected = false), ), + credentialChips = CredentialType.entries.map { type -> + FilterChipState(value = type, selected = type == CredentialType.Passkey) + }, ), isDefault = false, ), 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 a99261833..dfc50d617 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 @@ -308,7 +308,7 @@ private fun ItemListContentPreview() { itemTypeChips = emptyList(), tagChips = emptyList() ), - passwordSection = null, + loginSection = null, isDefault = true ) } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt index bcb4e8750..befccd2e3 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt @@ -1,5 +1,6 @@ package de.davis.keygo.feature.list_screen.presentation.mapper +import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.generated.domain.model.VaultItemType @@ -10,7 +11,7 @@ import de.davis.keygo.feature.list_screen.presentation.model.AvailableFilterOpti import de.davis.keygo.feature.list_screen.presentation.model.FacetOptions 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.PasswordSectionState +import de.davis.keygo.feature.list_screen.presentation.model.LoginSectionState internal fun FilterState.toBottomSheetState( available: AvailableFilterOptions, @@ -24,14 +25,19 @@ internal fun FilterState.toBottomSheetState( (itemTypeChips.size > 1 || (selections + retained)[FilterFacet.ItemTypes].isNotEmpty()) val onlyPinned = available.pinned.chips(selections, retained).singleOrNull() val tagChips = available.tags.chips(selections, retained) - val scoreChips = available.passwordScores.chips(selections, retained) val effectiveItemTypes = restrictedItemType?.let { setOf(it) } ?: selections[FilterFacet.ItemTypes] - val showLoginSection = scoreChips.isNotEmpty() && ( - effectiveItemTypes.isEmpty() || - effectiveItemTypes.any(FilterFacet.PasswordScores::appliesTo) - ) + + fun FacetOptions.chipsInScope() = + if (effectiveItemTypes.isEmpty() || effectiveItemTypes.any(facet::appliesTo)) + chips(selections, retained) + else emptyList() + + val loginSection = LoginSectionState( + passwordScoreChips = available.passwordScores.chipsInScope(), + credentialChips = available.credentials.chipsInScope(), + ) val itemSection = ItemSectionState( onlyPinned = onlyPinned, @@ -44,7 +50,9 @@ internal fun FilterState.toBottomSheetState( itemSection = itemSection.takeIf { it.onlyPinned != null || it.itemTypeChips.isNotEmpty() || it.tagChips.isNotEmpty() }, - passwordSection = if (showLoginSection) PasswordSectionState(scoreChips) else null, + loginSection = loginSection.takeIf { + it.passwordScoreChips.isNotEmpty() || it.credentialChips.isNotEmpty() + }, isDefault = isDefault, isVisible = isVisible, ) @@ -58,6 +66,7 @@ internal fun FacetSelections.toAvailableFilterOptions(allTags: List) = Avai available = allTags.filterTo(mutableSetOf()) { it in this[FilterFacet.Tags] }, ), passwordScores = options(FilterFacet.PasswordScores, PasswordScore.entries.reversed()), + credentials = options(FilterFacet.Credentials, CredentialType.entries), pinned = options(FilterFacet.Pinned, listOf(true)), ) diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt index 46aba30e7..fb4b70c73 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.list_screen.presentation.model import androidx.compose.runtime.Immutable +import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.generated.domain.model.VaultItemType @@ -12,6 +13,7 @@ internal data class AvailableFilterOptions( val itemTypes: FacetOptions, val tags: FacetOptions, val passwordScores: FacetOptions, + val credentials: FacetOptions, val pinned: FacetOptions, ) diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt index e15b1cb4d..b3abb150c 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.list_screen.presentation.model import androidx.compose.runtime.Immutable +import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.generated.domain.model.VaultItemType @@ -10,7 +11,7 @@ import de.davis.keygo.feature.list_screen.domain.model.SortDirection internal data class FilterBottomSheetState( val sortDirection: SortDirection = SortDirection.Ascending, val itemSection: ItemSectionState? = null, - val passwordSection: PasswordSectionState? = null, + val loginSection: LoginSectionState? = null, val isDefault: Boolean = true, val isVisible: Boolean = false, ) @@ -23,8 +24,9 @@ internal data class ItemSectionState( ) @Immutable -internal data class PasswordSectionState( +internal data class LoginSectionState( val passwordScoreChips: List>, + val credentialChips: List>, ) @Immutable diff --git a/feature/list_screen/src/main/res/values/strings.xml b/feature/list_screen/src/main/res/values/strings.xml index 51d05ed3f..a6000a941 100644 --- a/feature/list_screen/src/main/res/values/strings.xml +++ b/feature/list_screen/src/main/res/values/strings.xml @@ -41,10 +41,13 @@ Descending Password strength + Credentials + Passkey + TOTP Reset Item - Item Type + Item type Tags diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCaseTest.kt index 05a06c8ad..4280569ca 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCaseTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCaseTest.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.list_screen.domain.usecase import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.model.lite.LiteItem @@ -78,11 +79,12 @@ class AvailableFacetValuesUseCaseTest { } @Test - fun `no tags, scores, or pinned flag when nothing carries them`() { + fun `no tags, scores, credentials, or pinned flag when nothing carries them`() { val result = useCase(listOf(TestLiteItem("A")), ItemAttributes.None) assertTrue(result[FilterFacet.Tags].isEmpty()) assertTrue(result[FilterFacet.PasswordScores].isEmpty()) + assertTrue(result[FilterFacet.Credentials].isEmpty()) assertTrue(result[FilterFacet.Pinned].isEmpty()) } @@ -115,4 +117,27 @@ class AvailableFacetValuesUseCaseTest { assertEquals(setOf(true), result[FilterFacet.Pinned]) } + + @Test + fun `credentials are the union of what the listed logins hold`() { + val a = TestLiteItem("A") + val b = TestLiteItem("B") + val card = TestLiteItem("Card", itemType = VaultItemType.CreditCard) + + val result = useCase( + items = listOf(a, b, card), + attributes = ItemAttributes( + credentialsByItem = mapOf( + a.id to setOf(CredentialType.Password), + b.id to setOf(CredentialType.Password, CredentialType.Totp), + card.id to setOf(CredentialType.Passkey), + ), + ), + ) + + assertEquals( + setOf(CredentialType.Password, CredentialType.Totp), + result[FilterFacet.Credentials], + ) + } } diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt index ec234f829..a7f719518 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt @@ -2,6 +2,7 @@ package de.davis.keygo.feature.list_screen.domain.usecase 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.CredentialType import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.model.lite.LiteItem @@ -502,4 +503,53 @@ class FilterUseCaseTest { assertEquals(tagItems.size, result.size) assertTrue(result.containsAll(tagItems)) } -} \ No newline at end of file + + // Filter by credential (from the item -> credentials map) + private val passkeyLogin = TestLiteItem(name = "Passkey") + private val totpLogin = TestLiteItem(name = "Totp") + private val passwordLogin = TestLiteItem(name = "Password") + private val card = TestLiteItem(name = "Card", itemType = VaultItemType.CreditCard) + private val logins = listOf(passkeyLogin, totpLogin, passwordLogin) + + private val credentialAttributes = ItemAttributes( + credentialsByItem = mapOf( + passkeyLogin.id to setOf(CredentialType.Password, CredentialType.Passkey), + totpLogin.id to setOf(CredentialType.Totp), + passwordLogin.id to setOf(CredentialType.Password), + ), + ) + + @Test + fun `selecting a credential keeps only logins holding it`() { + val state = FilterState().with(FilterFacet.Credentials, setOf(CredentialType.Passkey)) + val result = useCase(state, logins, credentialAttributes) + + assertEquals(listOf(passkeyLogin.id), result.map { it.id }) + } + + @Test + fun `two selected credentials keep logins holding either`() { + val state = FilterState() + .with(FilterFacet.Credentials, setOf(CredentialType.Passkey, CredentialType.Totp)) + val result = useCase(state, logins, credentialAttributes) + + assertEquals(setOf(passkeyLogin.id, totpLogin.id), result.mapTo(mutableSetOf()) { it.id }) + } + + @Test + fun `a login without any credential entry is dropped by a credential filter`() { + val bare = TestLiteItem(name = "Bare") + val state = FilterState().with(FilterFacet.Credentials, setOf(CredentialType.Password)) + val result = useCase(state, listOf(bare, passwordLogin), credentialAttributes) + + assertEquals(listOf(passwordLogin.id), result.map { it.id }) + } + + @Test + fun `a credential filter does not apply to non-login items`() { + val state = FilterState().with(FilterFacet.Credentials, setOf(CredentialType.Totp)) + val result = useCase(state, listOf(card, passwordLogin, totpLogin), credentialAttributes) + + assertEquals(setOf(card.id, totpLogin.id), result.mapTo(mutableSetOf()) { it.id }) + } +} diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt index 73dd1dac7..1d519a074 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt @@ -6,14 +6,17 @@ import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.VaultId import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.EncryptedPayload import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Login +import de.davis.keygo.core.item.domain.model.PasskeyRef import de.davis.keygo.core.item.domain.model.PasswordCredential import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.PasswordSecret import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.model.Timestamp +import de.davis.keygo.core.item.passkeyRef import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.list_screen.domain.model.FilterFacet import de.davis.keygo.feature.list_screen.domain.model.FilterResult @@ -50,6 +53,7 @@ class ObserveFilterResultUseCaseTest { vault: VaultId = newVaultId(), tags: Set = emptySet(), passwordCredential: PasswordCredential? = null, + passkeys: Set = emptySet(), ) = Login( id = id, name = name, @@ -57,7 +61,7 @@ class ObserveFilterResultUseCaseTest { domainInfos = emptySet(), passwordCredential = passwordCredential, totp = null, - passkeys = emptySet(), + passkeys = passkeys, note = null, pinned = false, vaultId = vault, @@ -162,4 +166,24 @@ class ObserveFilterResultUseCaseTest { results.last().available[FilterFacet.PasswordScores] ) } + + @Test + fun `filters by a credential the login repository reports`() = runTest { + val withPasskey = login("With passkey", passkeys = setOf(passkeyRef("example.com"))) + val without = login("Without", passwordCredential = credential(PasswordScore.Strong)) + loginRepository.seed(withPasskey, without) + + val items = MutableStateFlow(listOf(withPasskey, without)) + val filterState = MutableStateFlow( + FilterState().with(FilterFacet.Credentials, setOf(CredentialType.Passkey)), + ) + + val result = useCase(items, filterState).first() + + assertEquals(listOf(withPasskey.id), result.items.map { it.id }) + assertEquals( + setOf(CredentialType.Passkey, CredentialType.Password), + result.available[FilterFacet.Credentials], + ) + } } diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapperTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapperTest.kt index 6a7ac464d..20f63c237 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapperTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapperTest.kt @@ -1,5 +1,6 @@ package de.davis.keygo.feature.list_screen.presentation.mapper +import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.generated.domain.model.VaultItemType @@ -37,7 +38,10 @@ class FilterMapperTest { defaultFacets.with(FilterFacet.PasswordScores, scores.toSet()) private fun FilterBottomSheetState.scoreChips() = - passwordSection?.passwordScoreChips.orEmpty().map { it.value to it.selected } + loginSection?.passwordScoreChips.orEmpty().map { it.value to it.selected } + + private fun FilterBottomSheetState.credentialChips() = + loginSection?.credentialChips.orEmpty().map { it.value to it.selected } @Test fun `tags are ordered by allTags and limited to the values present in the selections`() { @@ -185,4 +189,44 @@ class FilterMapperTest { sheet.itemSection?.tagChips, ) } + + @Test + fun `credential chips follow the credential order, whatever order they arrive in`() { + val sheet = sheetFor( + FilterState().with(FilterFacet.Credentials, setOf(CredentialType.Totp)), + facets = defaultFacets.with( + FilterFacet.Credentials, + setOf(CredentialType.Totp, CredentialType.Password), + ), + ) + + assertEquals( + listOf(CredentialType.Password to false, CredentialType.Totp to true), + sheet.credentialChips(), + ) + } + + @Test + fun `the login section shows for credentials alone, without score chips`() { + val sheet = sheetFor( + FilterState(), + facets = defaultFacets.with(FilterFacet.Credentials, setOf(CredentialType.Passkey)), + ) + + assertEquals(emptyList(), sheet.scoreChips()) + assertEquals(listOf(CredentialType.Passkey to false), sheet.credentialChips()) + } + + @Test + fun `no login section while the selected item types exclude logins`() { + val sheet = sheetFor( + FilterState().with(FilterFacet.ItemTypes, setOf(VaultItemType.CreditCard)), + facets = defaultFacets + .with(FilterFacet.ItemTypes, setOf(VaultItemType.Login, VaultItemType.CreditCard)) + .with(FilterFacet.Credentials, setOf(CredentialType.Passkey)) + .with(FilterFacet.PasswordScores, setOf(PasswordScore.Strong)), + ) + + assertNull(sheet.loginSection) + } } From 84e0b13a060b81d78e814f0903ea0ec71ec506e2 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Wed, 30 Sep 2026 12:15:27 +0200 Subject: [PATCH 8/9] fix: flash to create item card when deselecting filter --- .../components/ItemListContent.kt | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) 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 dfc50d617..9db21da65 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 @@ -174,28 +174,35 @@ internal fun ItemListContent( } } ) { innerPadding -> + val body = when { + uiState.items.isNotEmpty() -> ListBody.Items + uiState.isEmptyBecauseOfFilter -> ListBody.NoFilterMatches + !uiState.hasSearchQuery && notFoundStrategy is NoItemStrategy.ShowCreateNewItemCard -> + ListBody.CreateCard + + else -> ListBody.NotFound + } + AnimatedContent( - targetState = uiState.items.isEmpty(), + targetState = body, modifier = Modifier .padding(innerPadding) .padding(top = 4.dp) - ) { isEmpty -> - when (isEmpty) { - true -> { + ) { target -> + when (target) { + ListBody.NoFilterMatches, ListBody.CreateCard, ListBody.NotFound -> { Box( modifier = Modifier .fillMaxSize() .padding(16.dp), contentAlignment = Alignment.Center ) { - val showCreateCard = - !uiState.hasSearchQuery && notFoundStrategy is NoItemStrategy.ShowCreateNewItemCard - when { - uiState.isEmptyBecauseOfFilter -> NoFilterMatches( + when (target) { + ListBody.NoFilterMatches -> NoFilterMatches( onClearFilters = { onFilterAction(FilterAction.ClearFilters) }, ) - showCreateCard -> { + ListBody.CreateCard -> { val createTypes = remember(restrictedItemType) { restrictedItemType?.let { listOf(it) } ?: VaultItemType.entries @@ -224,7 +231,7 @@ internal fun ItemListContent( } } - false -> { + ListBody.Items -> { val expiryLabels = CardExpiryStatus.entries.associateWith { it.label() } val items = remember( uiState.items, @@ -347,6 +354,8 @@ private fun ItemListContentPreview() { } } +private enum class ListBody { Items, NoFilterMatches, CreateCard, NotFound } + @Composable private fun NoFilterMatches(onClearFilters: () -> Unit) { Column(horizontalAlignment = Alignment.CenterHorizontally) { From 5f66239b002fd94aaa6f28da13db97e6087d9c2f Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Wed, 30 Sep 2026 13:38:05 +0200 Subject: [PATCH 9/9] feat: add card expiry filter --- .../item/domain/model/CardExpiryStatus.kt | 4 +- .../list_screen/domain/model/FilterFacet.kt | 10 +++- .../list_screen/domain/model/FilterResult.kt | 3 ++ .../domain/model/ItemAttributes.kt | 2 + .../ObserveCardExpiryStatusesUseCase.kt | 3 +- .../usecase/ObserveFilterResultUseCase.kt | 3 ++ .../presentation/ItemListViewModel.kt | 9 +--- .../components/FilterBottomSheet.kt | 49 ++++++++++++++++++- .../presentation/mapper/FilterMapper.kt | 16 ++++-- .../model/AvailableFilterOptions.kt | 2 + .../model/FilterBottomSheetState.kt | 7 +++ .../src/main/res/values/strings.xml | 2 + .../AvailableFacetValuesUseCaseTest.kt | 19 +++++++ .../domain/usecase/FilterUseCaseTest.kt | 43 ++++++++++++++++ .../usecase/ObserveFilterResultUseCaseTest.kt | 3 ++ .../presentation/ItemListViewModelTest.kt | 2 +- .../presentation/mapper/FilterMapperTest.kt | 34 +++++++++++++ 17 files changed, 193 insertions(+), 18 deletions(-) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CardExpiryStatus.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CardExpiryStatus.kt index c673babac..721fcf529 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CardExpiryStatus.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CardExpiryStatus.kt @@ -3,9 +3,9 @@ package de.davis.keygo.core.item.domain.model import java.time.YearMonth enum class CardExpiryStatus { + Expired, ExpiresThisMonth, - ExpiresNextMonth, - Expired; + ExpiresNextMonth; companion object { fun of(expiration: YearMonth, today: YearMonth): CardExpiryStatus? = when { diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterFacet.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterFacet.kt index 566f13099..2434f4b84 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterFacet.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterFacet.kt @@ -1,5 +1,6 @@ package de.davis.keygo.feature.list_screen.domain.model +import de.davis.keygo.core.item.domain.model.CardExpiryStatus import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag @@ -41,10 +42,17 @@ sealed interface FilterFacet { if (item.pinned) setOf(true) else emptySet() } + data object CardExpiryStatuses : FilterFacet { + override fun appliesTo(type: VaultItemType): Boolean = type == VaultItemType.CreditCard + + override fun valuesFor(item: LiteItem, attributes: ItemAttributes) = + setOfNotNull(attributes.cardExpiryStatusByItem[item.id]) + } + companion object { // A getter, not a stored list: the interface initializes while its objects still are, so an // eager list would capture a null. val entries: List> - get() = listOf(ItemTypes, Tags, PasswordScores, Credentials, Pinned) + get() = listOf(ItemTypes, Tags, PasswordScores, Credentials, Pinned, CardExpiryStatuses) } } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterResult.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterResult.kt index 08aa9d431..96c1d20c2 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterResult.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterResult.kt @@ -1,9 +1,12 @@ package de.davis.keygo.feature.list_screen.domain.model +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.CardExpiryStatus import de.davis.keygo.core.item.domain.model.lite.LiteItem data class FilterResult( val items: List, val available: FacetSelections, val isEmptyBecauseOfFilter: Boolean, + val cardExpiryStatuses: Map, ) diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemAttributes.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemAttributes.kt index b198b889f..2a275106a 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemAttributes.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemAttributes.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.list_screen.domain.model import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.CardExpiryStatus import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag @@ -9,6 +10,7 @@ data class ItemAttributes( val passwordScoreByItem: Map = emptyMap(), val tagsByItem: Map> = emptyMap(), val credentialsByItem: Map> = emptyMap(), + val cardExpiryStatusByItem: Map = emptyMap(), ) { companion object { val None = ItemAttributes() diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveCardExpiryStatusesUseCase.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveCardExpiryStatusesUseCase.kt index 74e8d0be1..4ca7a8bd4 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveCardExpiryStatusesUseCase.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveCardExpiryStatusesUseCase.kt @@ -4,6 +4,7 @@ import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.CardExpiryStatus import de.davis.keygo.core.item.domain.repository.CreditCardRepository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single import java.time.YearMonth @@ -21,5 +22,5 @@ class ObserveCardExpiryStatusesUseCase( CardExpiryStatus.of(expiration, today)?.let { put(id, it) } } } - } + }.distinctUntilChanged() } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCase.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCase.kt index ac71d8c30..d44a4271b 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCase.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCase.kt @@ -16,6 +16,7 @@ class ObserveFilterResultUseCase( private val itemRepository: ItemRepository, private val loginRepository: LoginRepository, private val filterUseCase: FilterUseCase, + private val observeCardExpiryStatuses: ObserveCardExpiryStatusesUseCase, private val availableFacetValues: AvailableFacetValuesUseCase, ) { @@ -27,6 +28,7 @@ class ObserveFilterResultUseCase( loginRepository.observePasswordScores(), itemRepository.observeTagsByItem(), loginRepository.observeCredentialTypes(), + observeCardExpiryStatuses(), ::ItemAttributes, ) return combine(items, filterState, attributes) { items, filter, attributes -> @@ -35,6 +37,7 @@ class ObserveFilterResultUseCase( items = filtered, available = availableFacetValues(items, attributes), isEmptyBecauseOfFilter = filtered.isEmpty() && items.isNotEmpty(), + cardExpiryStatuses = attributes.cardExpiryStatusByItem, ) }.distinctUntilChanged() } 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 6940e64b8..bbbcd596b 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 @@ -15,7 +15,6 @@ 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.FacetSelections import de.davis.keygo.feature.list_screen.domain.model.FilterState -import de.davis.keygo.feature.list_screen.domain.usecase.ObserveCardExpiryStatusesUseCase import de.davis.keygo.feature.list_screen.domain.usecase.ObserveFilterResultUseCase import de.davis.keygo.feature.list_screen.domain.usecase.RankSearchResultsUseCase import de.davis.keygo.feature.list_screen.presentation.mapper.toAvailableFilterOptions @@ -67,7 +66,6 @@ internal class ItemListViewModel( observeAllTags: ObserveAllTagsSortedUseCase, observeVaultsAndSelection: ObserveVaultsAndSelectionUseCase, observeFilterResult: ObserveFilterResultUseCase, - observeCardExpiryStatuses: ObserveCardExpiryStatusesUseCase, ) : ViewModel() { private val vaultsAndSelection = observeVaultsAndSelection() @@ -84,8 +82,6 @@ internal class ItemListViewModel( .flatMapLatest(::queryToItems) .distinctUntilChanged() - private val cardExpiryStatuses = observeCardExpiryStatuses().distinctUntilChanged() - private val filterState = MutableStateFlow(FilterState.Default) private val isFilterSheetVisible = MutableStateFlow(false) @@ -125,12 +121,11 @@ internal class ItemListViewModel( submittedSearchQuery, _isVaultFlowVisible, _isDeleteConfirmationVisible, - cardExpiryStatuses, - ) { vaultsAndSel, filterResult, searchState, selection, submittedSearchQuery, isVaultFlowVisible, isDeleteConfirmationVisible, expiryStatuses -> + ) { vaultsAndSel, filterResult, searchState, selection, submittedSearchQuery, isVaultFlowVisible, isDeleteConfirmationVisible -> ListItemState( items = filterResult.items, isEmptyBecauseOfFilter = filterResult.isEmptyBecauseOfFilter, - cardExpiryStatuses = expiryStatuses, + cardExpiryStatuses = filterResult.cardExpiryStatuses, searchState = searchState, hasSearchQuery = submittedSearchQuery.isNotBlank(), selection = selection, diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt index 357969d71..9a9acb0ac 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt @@ -45,11 +45,13 @@ import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.model.CardExpiryStatus import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.item.generated.presentation.presentation +import de.davis.keygo.core.item.presentation.label import de.davis.keygo.core.ui.components.KeyGoCard import de.davis.keygo.core.ui.components.KeyGoCardProperties import de.davis.keygo.core.ui.components.KeyGoSwitch @@ -57,6 +59,7 @@ import de.davis.keygo.core.ui.theme.KeyGoTheme import de.davis.keygo.feature.list_screen.R import de.davis.keygo.feature.list_screen.domain.model.FilterFacet import de.davis.keygo.feature.list_screen.domain.model.SortDirection +import de.davis.keygo.feature.list_screen.presentation.model.CreditCardSectionState 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.FilterChipState @@ -143,6 +146,16 @@ private fun FilterBottomSheetContent( ) } } + + if (state.creditCardSection != null) { + item(key = "credit_cards") { + CreditCardSection( + state = state.creditCardSection, + onAction = onAction, + modifier = Modifier.animateItem(), + ) + } + } } } @@ -275,8 +288,8 @@ private fun LoginSection( verticalArrangement = DefaultHorizontalArrangement, ) { SectionHeader( - icon = Icons.Default.Password, - title = stringResource(CoreItemR.string.login), + icon = VaultItemType.Login.presentation.second, + title = VaultItemType.Login.presentation.first, ) if (state.passwordScoreChips.isNotEmpty()) { @@ -314,6 +327,38 @@ private fun LoginSection( } } +@Composable +private fun CreditCardSection( + state: CreditCardSectionState, + onAction: (FilterAction) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = DefaultHorizontalArrangement, + ) { + SectionHeader( + icon = VaultItemType.CreditCard.presentation.second, + title = VaultItemType.CreditCard.presentation.first, + ) + + if (state.expiryStatusChips.isNotEmpty()) { + KeyGoCard( + title = { + Text(text = stringResource(R.string.expiry_status)) + }, + ) { + FacetChips( + facet = FilterFacet.CardExpiryStatuses, + chips = state.expiryStatusChips, + onAction = onAction, + label = { Text(text = it.label()) }, + ) + } + } + } +} + @Composable private fun FacetChips( facet: FilterFacet, diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt index befccd2e3..0b54801f7 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt @@ -1,5 +1,6 @@ package de.davis.keygo.feature.list_screen.presentation.mapper +import de.davis.keygo.core.item.domain.model.CardExpiryStatus import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag @@ -8,6 +9,7 @@ import de.davis.keygo.feature.list_screen.domain.model.FacetSelections import de.davis.keygo.feature.list_screen.domain.model.FilterFacet import de.davis.keygo.feature.list_screen.domain.model.FilterState import de.davis.keygo.feature.list_screen.presentation.model.AvailableFilterOptions +import de.davis.keygo.feature.list_screen.presentation.model.CreditCardSectionState import de.davis.keygo.feature.list_screen.presentation.model.FacetOptions import de.davis.keygo.feature.list_screen.presentation.model.FilterBottomSheetState import de.davis.keygo.feature.list_screen.presentation.model.ItemSectionState @@ -34,15 +36,19 @@ internal fun FilterState.toBottomSheetState( chips(selections, retained) else emptyList() + val itemSection = ItemSectionState( + onlyPinned = onlyPinned, + itemTypeChips = if (showItemTypeChips) itemTypeChips else emptyList(), + tagChips = tagChips, + ) + val loginSection = LoginSectionState( passwordScoreChips = available.passwordScores.chipsInScope(), credentialChips = available.credentials.chipsInScope(), ) - val itemSection = ItemSectionState( - onlyPinned = onlyPinned, - itemTypeChips = if (showItemTypeChips) itemTypeChips else emptyList(), - tagChips = tagChips, + val creditCardSection = CreditCardSectionState( + expiryStatusChips = available.expiryStatuses.chipsInScope(), ) return FilterBottomSheetState( @@ -53,6 +59,7 @@ internal fun FilterState.toBottomSheetState( loginSection = loginSection.takeIf { it.passwordScoreChips.isNotEmpty() || it.credentialChips.isNotEmpty() }, + creditCardSection = creditCardSection.takeIf { it.expiryStatusChips.isNotEmpty() }, isDefault = isDefault, isVisible = isVisible, ) @@ -67,6 +74,7 @@ internal fun FacetSelections.toAvailableFilterOptions(allTags: List) = Avai ), passwordScores = options(FilterFacet.PasswordScores, PasswordScore.entries.reversed()), credentials = options(FilterFacet.Credentials, CredentialType.entries), + expiryStatuses = options(FilterFacet.CardExpiryStatuses, CardExpiryStatus.entries), pinned = options(FilterFacet.Pinned, listOf(true)), ) diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt index fb4b70c73..f4f60eab7 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/AvailableFilterOptions.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.list_screen.presentation.model import androidx.compose.runtime.Immutable +import de.davis.keygo.core.item.domain.model.CardExpiryStatus import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag @@ -14,6 +15,7 @@ internal data class AvailableFilterOptions( val tags: FacetOptions, val passwordScores: FacetOptions, val credentials: FacetOptions, + val expiryStatuses: FacetOptions, val pinned: FacetOptions, ) diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt index b3abb150c..8d4980eb7 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/FilterBottomSheetState.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.list_screen.presentation.model import androidx.compose.runtime.Immutable +import de.davis.keygo.core.item.domain.model.CardExpiryStatus import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag @@ -12,6 +13,7 @@ internal data class FilterBottomSheetState( val sortDirection: SortDirection = SortDirection.Ascending, val itemSection: ItemSectionState? = null, val loginSection: LoginSectionState? = null, + val creditCardSection: CreditCardSectionState? = null, val isDefault: Boolean = true, val isVisible: Boolean = false, ) @@ -29,6 +31,11 @@ internal data class LoginSectionState( val credentialChips: List>, ) +@Immutable +internal data class CreditCardSectionState( + val expiryStatusChips: List>, +) + @Immutable internal data class FilterChipState( val value: T, diff --git a/feature/list_screen/src/main/res/values/strings.xml b/feature/list_screen/src/main/res/values/strings.xml index a6000a941..b695f1a4a 100644 --- a/feature/list_screen/src/main/res/values/strings.xml +++ b/feature/list_screen/src/main/res/values/strings.xml @@ -49,6 +49,8 @@ Item Item type + Expiry status + Tags Only pinned Items diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCaseTest.kt index 4280569ca..226661e15 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCaseTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCaseTest.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.list_screen.domain.usecase import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.CardExpiryStatus import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag @@ -140,4 +141,22 @@ class AvailableFacetValuesUseCaseTest { result[FilterFacet.Credentials], ) } + + @Test + fun `expiry statuses are drawn only from items typed as credit cards`() { + val card = TestLiteItem("Card", itemType = VaultItemType.CreditCard) + val login = TestLiteItem("Login") + + val result = useCase( + items = listOf(card, login), + attributes = ItemAttributes( + cardExpiryStatusByItem = mapOf( + card.id to CardExpiryStatus.Expired, + login.id to CardExpiryStatus.ExpiresNextMonth, + ), + ), + ) + + assertEquals(setOf(CardExpiryStatus.Expired), result[FilterFacet.CardExpiryStatuses]) + } } diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt index a7f719518..be52420d0 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt @@ -2,6 +2,7 @@ package de.davis.keygo.feature.list_screen.domain.usecase 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.CardExpiryStatus import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag @@ -552,4 +553,46 @@ class FilterUseCaseTest { assertEquals(setOf(card.id, totpLogin.id), result.mapTo(mutableSetOf()) { it.id }) } + + // Filter by card expiry status (from the item -> status map) + private val expiredCard = TestLiteItem(name = "Expired", itemType = VaultItemType.CreditCard) + private val expiringCard = TestLiteItem(name = "Expiring", itemType = VaultItemType.CreditCard) + private val validCard = TestLiteItem(name = "Valid", itemType = VaultItemType.CreditCard) + private val cards = listOf(expiredCard, expiringCard, validCard) + + private val expiryAttributes = ItemAttributes( + cardExpiryStatusByItem = mapOf( + expiredCard.id to CardExpiryStatus.Expired, + expiringCard.id to CardExpiryStatus.ExpiresThisMonth, + ), + ) + + @Test + fun `selecting an expiry status keeps only cards in it`() { + val state = FilterState() + .with(FilterFacet.CardExpiryStatuses, setOf(CardExpiryStatus.Expired)) + val result = useCase(state, cards, expiryAttributes) + + assertEquals(listOf(expiredCard.id), result.map { it.id }) + } + + @Test + fun `a card with no expiry status is dropped by an expiry filter`() { + val state = FilterState().with( + FilterFacet.CardExpiryStatuses, + setOf(CardExpiryStatus.Expired, CardExpiryStatus.ExpiresThisMonth), + ) + val result = useCase(state, cards, expiryAttributes) + + assertEquals(setOf(expiredCard.id, expiringCard.id), result.mapTo(mutableSetOf()) { it.id }) + } + + @Test + fun `an expiry filter does not apply to non-card items`() { + val state = FilterState() + .with(FilterFacet.CardExpiryStatuses, setOf(CardExpiryStatus.Expired)) + val result = useCase(state, listOf(passwordLogin) + cards, expiryAttributes) + + assertEquals(setOf(passwordLogin.id, expiredCard.id), result.mapTo(mutableSetOf()) { it.id }) + } } diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt index 1d519a074..1cc289768 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt @@ -1,5 +1,6 @@ package de.davis.keygo.feature.list_screen.domain.usecase +import de.davis.keygo.core.item.FakeCreditCardRepository import de.davis.keygo.core.item.FakeItemRepository import de.davis.keygo.core.item.FakeLoginRepository import de.davis.keygo.core.item.domain.alias.ItemId @@ -38,12 +39,14 @@ class ObserveFilterResultUseCaseTest { private val loginRepository = FakeLoginRepository() private val itemRepository = FakeItemRepository(loginRepository) + private val creditCardRepository = FakeCreditCardRepository() private val sortUseCase = SortUseCase() private val useCase = ObserveFilterResultUseCase( itemRepository = itemRepository, loginRepository = loginRepository, filterUseCase = FilterUseCase(sortUseCase), + observeCardExpiryStatuses = ObserveCardExpiryStatusesUseCase(creditCardRepository), availableFacetValues = AvailableFacetValuesUseCase(), ) 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 8adb0e4d1..afb01dc4e 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 @@ -86,9 +86,9 @@ class ItemListViewModelTest { itemRepository = itemRepository, loginRepository = loginRepository, filterUseCase = FilterUseCase(sortUseCase), + observeCardExpiryStatuses = ObserveCardExpiryStatusesUseCase(creditCardRepository), availableFacetValues = AvailableFacetValuesUseCase(), ), - observeCardExpiryStatuses = ObserveCardExpiryStatusesUseCase(creditCardRepository), ) private fun login( diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapperTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapperTest.kt index 20f63c237..afa7bfe7d 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapperTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapperTest.kt @@ -1,5 +1,6 @@ package de.davis.keygo.feature.list_screen.presentation.mapper +import de.davis.keygo.core.item.domain.model.CardExpiryStatus import de.davis.keygo.core.item.domain.model.CredentialType import de.davis.keygo.core.item.domain.model.PasswordScore import de.davis.keygo.core.item.domain.model.Tag @@ -229,4 +230,37 @@ class FilterMapperTest { assertNull(sheet.loginSection) } + + @Test + fun `expiry chips follow the expiry status order, whatever order they arrive in`() { + val sheet = sheetFor( + FilterState(), + facets = defaultFacets.with( + FilterFacet.CardExpiryStatuses, + CardExpiryStatus.entries.reversed().toSet(), + ), + ) + + assertEquals( + CardExpiryStatus.entries, + sheet.creditCardSection?.expiryStatusChips.orEmpty().map { it.value }, + ) + } + + @Test + fun `no credit card section while no card carries an expiry status`() { + assertNull(sheetFor(FilterState()).creditCardSection) + } + + @Test + fun `no credit card section while the selected item types exclude credit cards`() { + val sheet = sheetFor( + FilterState().with(FilterFacet.ItemTypes, setOf(VaultItemType.Login)), + facets = defaultFacets + .with(FilterFacet.ItemTypes, setOf(VaultItemType.Login, VaultItemType.CreditCard)) + .with(FilterFacet.CardExpiryStatuses, setOf(CardExpiryStatus.Expired)), + ) + + assertNull(sheet.creditCardSection) + } }