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/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/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/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/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/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/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/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/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/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 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/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..d866f803e --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FacetSelections.kt @@ -0,0 +1,33 @@ +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 + + // 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..2434f4b84 --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterFacet.kt @@ -0,0 +1,58 @@ +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 +import de.davis.keygo.core.item.domain.model.lite.LiteItem +import de.davis.keygo.core.item.generated.domain.model.VaultItemType + +sealed interface 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 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() + } + + 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, 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 new file mode 100644 index 000000000..96c1d20c2 --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterResult.kt @@ -0,0 +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/FilterState.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/FilterState.kt index 11bd7e975..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 @@ -1,20 +1,16 @@ 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 + 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/model/ItemAttributes.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemAttributes.kt new file mode 100644 index 000000000..2a275106a --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/model/ItemAttributes.kt @@ -0,0 +1,18 @@ +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 + +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/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..a39fb5e10 --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCase.kt @@ -0,0 +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.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.ItemAttributes +import org.koin.core.annotation.Single + +@Single +class AvailableFacetValuesUseCase { + + operator fun invoke(items: List, attributes: ItemAttributes): FacetSelections = + FilterFacet.entries.fold(FacetSelections.None) { available, facet -> + available.withValuesOf(facet, items, attributes) + } + + 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 0e244dbdd..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,11 +1,11 @@ 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.ItemAttributes import de.davis.keygo.feature.list_screen.domain.model.SortDirection import org.koin.core.annotation.Single @@ -17,14 +17,13 @@ class FilterUseCase( operator fun invoke( filterState: FilterState, items: List, - passwordScores: Map, - tagMatchingIds: Set? = null, + attributes: ItemAttributes, ): 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 + .filter { it.appliesTo(item.itemType) } + .all { facet -> facet.matches(item, selections, attributes) } } val (pinned, unpinned) = filtered.partition { it.pinned } @@ -32,26 +31,11 @@ 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, + private fun FilterFacet.matches( 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 - } + selections: FacetSelections, + 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/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 new file mode 100644 index 000000000..d44a4271b --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCase.kt @@ -0,0 +1,44 @@ +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.ItemAttributes +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 observeCardExpiryStatuses: ObserveCardExpiryStatusesUseCase, + private val availableFacetValues: AvailableFacetValuesUseCase, +) { + + operator fun invoke( + items: Flow>, + filterState: Flow, + ): Flow> { + val attributes = combine( + loginRepository.observePasswordScores(), + itemRepository.observeTagsByItem(), + loginRepository.observeCredentialTypes(), + observeCardExpiryStatuses(), + ::ItemAttributes, + ) + return combine(items, filterState, attributes) { items, filter, attributes -> + val filtered = filterUseCase(filter, items, attributes) + FilterResult( + 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/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 1879c3ea7..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 @@ -10,13 +10,12 @@ 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 +37,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 +62,10 @@ 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, - observeCardExpiryStatuses: ObserveCardExpiryStatusesUseCase, - loginRepository: LoginRepository, + observeFilterResult: ObserveFilterResultUseCase, ) : ViewModel() { private val vaultsAndSelection = observeVaultsAndSelection() @@ -84,29 +82,17 @@ internal class ItemListViewModel( .flatMapLatest(::queryToItems) .distinctUntilChanged() - private val passwordScores = loginRepository.observePasswordScores() - - 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, - filterState, - passwordScores, - tagFilteredItemIds, - ) { items, filter, scores, tagIds -> - filterUseCase(filter, items, scores, tagIds) - }.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) @@ -129,17 +115,17 @@ internal class ItemListViewModel( val listItemState = combine( vaultsAndSelection, - filteredItems, + filterResult, searchState, selection, submittedSearchQuery, _isVaultFlowVisible, _isDeleteConfirmationVisible, - cardExpiryStatuses, - ) { vaultsAndSel, items, searchState, selection, submittedSearchQuery, isVaultFlowVisible, isDeleteConfirmationVisible, expiryStatuses -> + ) { vaultsAndSel, filterResult, searchState, selection, submittedSearchQuery, isVaultFlowVisible, isDeleteConfirmationVisible -> ListItemState( - items = items, - cardExpiryStatuses = expiryStatuses, + items = filterResult.items, + isEmptyBecauseOfFilter = filterResult.isEmptyBecauseOfFilter, + cardExpiryStatuses = filterResult.cardExpiryStatuses, searchState = searchState, hasSearchQuery = submittedSearchQuery.isNotBlank(), selection = selection, @@ -155,20 +141,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), @@ -181,29 +166,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() { @@ -214,9 +195,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/FilterBottomSheet.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt index 5498f141e..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 @@ -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,21 +45,26 @@ 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 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 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) @@ -130,11 +137,21 @@ private fun FilterBottomSheetContent( } } - if (state.passwordSection != null) { - item(key = "passwords") { - PasswordSection( - state = state.passwordSection, - onScoreToggled = { onAction(FilterAction.ScoreToggled(it)) }, + if (state.loginSection != null) { + item(key = "logins") { + LoginSection( + state = state.loginSection, + onAction = onAction, + modifier = Modifier.animateItem(), + ) + } + } + + if (state.creditCardSection != null) { + item(key = "credit_cards") { + CreditCardSection( + state = state.creditCardSection, + onAction = onAction, modifier = Modifier.animateItem(), ) } @@ -158,11 +175,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 +203,15 @@ 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 = { Text(text = it.presentation.first) }, + leadingIcon = { + Icon(imageVector = it.presentation.second, contentDescription = null) + }, + ) } } @@ -211,17 +222,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 = { Text(text = it.display) }, + ) } } } @@ -272,9 +278,9 @@ private fun SortSection( } @Composable -private fun PasswordSection( - state: PasswordSectionState, - onScoreToggled: (PasswordScore) -> Unit, +private fun LoginSection( + state: LoginSectionState, + onAction: (FilterAction) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -282,30 +288,97 @@ private fun PasswordSection( verticalArrangement = DefaultHorizontalArrangement, ) { SectionHeader( - icon = Icons.Default.Password, - title = stringResource(CoreItemR.string.login), + icon = VaultItemType.Login.presentation.second, + title = VaultItemType.Login.presentation.first, ) - KeyGoCard( - title = { - 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()) - }, - ) - } + 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) + }, + ) } } } } +@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, + chips: List>, + onAction: (FilterAction) -> Unit, + 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 = { label(chip.value) }, + leadingIcon = leadingIcon?.let { { it(chip.value) } }, + ) + } + } +} + @Composable private fun SectionHeader( icon: ImageVector, @@ -338,6 +411,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) @@ -362,8 +448,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) }, @@ -372,7 +457,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), @@ -380,6 +465,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 571f01997..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 @@ -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,14 +23,11 @@ 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 -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 @@ -60,6 +58,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 @@ -78,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, @@ -91,8 +92,6 @@ internal fun ItemListContent( scrollBehavior: SearchBarScrollBehavior, modifier: Modifier = Modifier ) { - var showFilterSheet by rememberSaveable { mutableStateOf(false) } - val searchInputField = @Composable { ListSearchTextField( searchTextFieldState = searchTextFieldState, @@ -100,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) @@ -175,24 +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 (showCreateCard) { - true -> { + when (target) { + ListBody.NoFilterMatches -> NoFilterMatches( + onClearFilters = { onFilterAction(FilterAction.ClearFilters) }, + ) + + ListBody.CreateCard -> { val createTypes = remember(restrictedItemType) { restrictedItemType?.let { listOf(it) } ?: VaultItemType.entries @@ -216,12 +226,12 @@ internal fun ItemListContent( } } - false -> Text(text = stringResource(R.string.match_not_found)) + else -> Text(text = stringResource(R.string.match_not_found)) } } } - false -> { + ListBody.Items -> { val expiryLabels = CardExpiryStatus.entries.associateWith { it.label() } val items = remember( uiState.items, @@ -301,12 +311,11 @@ private fun ItemListContentPreview() { FilterBottomSheetState( sortDirection = SortDirection.Ascending, itemSection = ItemSectionState( - showPinnedSwitch = false, - onlyPinnedChecked = false, + onlyPinned = null, itemTypeChips = emptyList(), tagChips = emptyList() ), - passwordSection = null, + loginSection = null, isDefault = true ) } @@ -327,6 +336,8 @@ private fun ItemListContentPreview() { onSubmitQuery = {}, onClearQuery = {}, onFilterAction = {}, + onShowFilterSheet = {}, + onDismissFilterSheet = {}, onItemClick = { _, _ -> }, onItemLongClick = {}, onClearSelection = {}, @@ -342,3 +353,15 @@ private fun ItemListContentPreview() { } } } + +private enum class ListBody { Items, NoFilterMatches, CreateCard, NotFound } + +@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/mapper/FilterMapper.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt index 846836467..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,79 +1,82 @@ 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.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.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.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.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 internal fun FilterState.toBottomSheetState( available: AvailableFilterOptions, restrictedItemType: VaultItemType?, + retained: FacetSelections, + isVisible: Boolean, ): FilterBottomSheetState { - val showItemTypeChips = restrictedItemType == null && available.itemTypes.size > 1 - val showItemSection = - showItemTypeChips || available.tags.isNotEmpty() || available.hasPinnedItems + 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 effectiveItemTypes = restrictedItemType?.let { setOf(it) } ?: selectedItemTypes - val showLoginSection = available.hasPasswordItems && - (effectiveItemTypes.isEmpty() || VaultItemType.Login in effectiveItemTypes) + val effectiveItemTypes = + restrictedItemType?.let { setOf(it) } ?: selections[FilterFacet.ItemTypes] + + fun FacetOptions.chipsInScope() = + if (effectiveItemTypes.isEmpty() || effectiveItemTypes.any(facet::appliesTo)) + 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 creditCardSection = CreditCardSectionState( + expiryStatusChips = available.expiryStatuses.chipsInScope(), + ) 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() + }, + loginSection = loginSection.takeIf { + it.passwordScoreChips.isNotEmpty() || it.credentialChips.isNotEmpty() + }, + creditCardSection = creditCardSection.takeIf { it.expiryStatusChips.isNotEmpty() }, 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() +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()), + credentials = options(FilterFacet.Credentials, CredentialType.entries), + expiryStatuses = options(FilterFacet.CardExpiryStatuses, CardExpiryStatus.entries), + pinned = options(FilterFacet.Pinned, listOf(true)), +) - val visibleTags = buildSet { - this@toAvailableFilterOptions.forEach { item -> - tagsByItem[item.id]?.let(::addAll) - } - } - - return AvailableFilterOptions( - itemTypes = itemTypes, - hasPasswordItems = hasLoginItems, - passwordScores = scores, - tags = allTags.filter { it in visibleTags }.toSet(), - hasPinnedItems = any { it.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 5246e80dd..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,15 +1,36 @@ 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 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, + val tags: FacetOptions, + val passwordScores: FacetOptions, + val credentials: FacetOptions, + val expiryStatuses: FacetOptions, + val pinned: FacetOptions, ) + +@Immutable +internal data class FacetOptions( + val facet: FilterFacet, + val order: List, + val available: Set, +) { + + 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 } // drops the ordered values that shouldn't be 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/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..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,8 @@ 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 import de.davis.keygo.core.item.generated.domain.model.VaultItemType @@ -10,21 +12,28 @@ 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 creditCardSection: CreditCardSectionState? = null, val isDefault: Boolean = true, + val isVisible: Boolean = false, ) @Immutable internal data class ItemSectionState( - val showPinnedSwitch: Boolean, - val onlyPinnedChecked: Boolean, + val onlyPinned: FilterChipState?, val itemTypeChips: List>, val tagChips: List>, ) @Immutable -internal data class PasswordSectionState( +internal data class LoginSectionState( val passwordScoreChips: List>, + val credentialChips: List>, +) + +@Immutable +internal data class CreditCardSectionState( + val expiryStatusChips: List>, ) @Immutable 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..b695f1a4a 100644 --- a/feature/list_screen/src/main/res/values/strings.xml +++ b/feature/list_screen/src/main/res/values/strings.xml @@ -41,12 +41,20 @@ Descending Password strength + Credentials + Passkey + TOTP Reset Item - Item Type + Item type + + Expiry status 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/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..911e9070d --- /dev/null +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/model/FacetSelectionsTest.kt @@ -0,0 +1,53 @@ +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) + } + + @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]) + } +} 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..226661e15 --- /dev/null +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/AvailableFacetValuesUseCaseTest.kt @@ -0,0 +1,162 @@ +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 +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.ItemAttributes +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), + ), + attributes = ItemAttributes.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), + attributes = ItemAttributes( + 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), + attributes = ItemAttributes( + 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, 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()) + } + + @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), + 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. + 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")), + attributes = ItemAttributes.None, + ) + + 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], + ) + } + + @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 9fc63d384..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,11 +2,16 @@ 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 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.ItemAttributes import de.davis.keygo.feature.list_screen.domain.model.SortDirection import java.util.UUID import kotlin.test.Test @@ -34,7 +39,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, ItemAttributes.None) assertEquals(expected, result) } @@ -42,7 +47,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, ItemAttributes.None) assertEquals(expected, result) } @@ -69,8 +74,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(), noAttributes) assertTrue(result.isEmpty()) } @@ -79,7 +85,7 @@ class FilterUseCaseTest { val result = useCase( filterStateAsc, items("cherry", "Banana", "apple", "Apple", "banana"), - emptyMap() + ItemAttributes.None ) // At Collator.PRIMARY, Apple/apple are equal and banana/Banana are equal, so @@ -106,7 +112,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"), ItemAttributes.None) assertEquals(3, result.size) assertTrue(result.all { it.name == "same" }) } @@ -114,7 +120,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"), ItemAttributes.None) val lastSpecialIndex = maxOf( result.indexOfFirst { it.name == "/path" }, result.indexOfFirst { it.name == "&tag" } @@ -129,15 +136,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, 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, emptyMap()) + val result = useCase(filterStateAsc, input, ItemAttributes.None) assertEquals(4, result.size) assertTrue(result.map { it.name }.containsAll(input.map { it.name })) } @@ -145,8 +152,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, ItemAttributes.None) + val desc = useCase(filterStateDesc, input, ItemAttributes.None) assertEquals(asc.reversed(), desc) } @@ -184,14 +191,18 @@ 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", " ", ""), 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"), emptyMap()) + useCase( + filterStateAsc, + items("Banana", "banana", "apple", "Apple"), + ItemAttributes.None + ) val lastAppleIndex = maxOf( result.indexOfFirst { it.name == "apple" }, result.indexOfFirst { it.name == "Apple" } @@ -211,8 +222,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, ItemAttributes.None) + val desc = useCase(filterStateDesc, input, ItemAttributes.None) assertEquals(asc.reversed(), desc) } @@ -220,15 +231,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, ItemAttributes.None) + val result2 = useCase(filterStateAsc, input, ItemAttributes.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"), ItemAttributes.None).size + ) } // Filter by item type @@ -237,19 +251,19 @@ class FilterUseCaseTest { TestLiteItem(name = "Login B", id = newItemId(), itemType = VaultItemType.Login), ) - private val noScores: Map = emptyMap() + private val noAttributes = ItemAttributes.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, noAttributes) 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, noAttributes) assertTrue(result.all { it.itemType == VaultItemType.Login }) } @@ -268,19 +282,26 @@ class FilterUseCaseTest { scoredItems[3].id to PasswordScore.Weak, ) + private val scoreAttributes = ItemAttributes(passwordScoreByItem = 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, + scoreAttributes + ).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, scoreAttributes) val expected = scoredItems.filter { scores[it.id] == PasswordScore.Weak } assertEquals(expected, result) @@ -288,10 +309,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, scoreAttributes) val expected = scoredItems.filter { scores[it.id] == PasswordScore.Weak || @@ -304,33 +324,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, scoreAttributes) 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, scoreAttributes) 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, scoreAttributes) val ascState = state.copy(sortDirection = SortDirection.Ascending) - val ascResult = useCase(ascState, scoredItems, scores) + val ascResult = useCase(ascState, scoredItems, scoreAttributes) assertEquals(ascResult.reversed(), result) } @@ -345,7 +363,7 @@ class FilterUseCaseTest { @Test fun `pinned items appear before unpinned items`() { - val result = useCase(filterStateAsc, mixedPinnedItems, noScores) + val result = useCase(filterStateAsc, mixedPinnedItems, noAttributes) val pinnedNames = result.takeWhile { it.pinned }.map { it.name } val unpinnedNames = result.dropWhile { it.pinned }.map { it.name } @@ -356,7 +374,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, noAttributes) val pinnedNames = result.takeWhile { it.pinned }.map { it.name } val unpinnedNames = result.dropWhile { it.pinned }.map { it.name } @@ -367,8 +385,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, noAttributes) assertTrue(result.all { it.pinned }) assertEquals(2, result.size) @@ -376,28 +394,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, noAttributes) 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, noAttributes) 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, noAttributes) assertTrue(result.all { it.pinned }) assertTrue(result.all { it.itemType == VaultItemType.Login }) @@ -410,47 +427,172 @@ class FilterUseCaseTest { TestLiteItem(name = "Alpha", pinned = true), TestLiteItem(name = "Mike", pinned = true), ) - val result = useCase(filterStateAsc, allPinned, noScores) + val result = useCase(filterStateAsc, allPinned, noAttributes) 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 `an item carrying the selected tag passes while others are dropped`() { + 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, attributes) + + assertEquals(listOf(tagItems[0].id), result.map { it.id }) + } + + @Test + fun `two selected tags keep items carrying either`() { + val state = FilterState() + .with(FilterFacet.Tags, setOf(Tag.of("Bank")!!, Tag.of("Work")!!)) + 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, attributes) + + assertEquals(setOf(tagItems[0].id, tagItems[2].id), result.mapTo(mutableSetOf()) { it.id }) + } + + @Test + fun `a selected tag matches an item tagged with different casing`() { + val state = FilterState().with(FilterFacet.Tags, setOf(Tag.of("bank")!!)) + 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 }) + } + + @Test + fun `tag filter combines with item type filter`() { + 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 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), attributes) + + assertEquals(listOf(loginBank.id), result.map { it.id }) + } + @Test - fun `null tagMatchingIds returns all items`() { - val result = useCase(FilterState(), tagItems, noScores, null) + fun `no Tags selection keeps untagged items`() { + val result = useCase(FilterState(), tagItems, noAttributes) + assertEquals(tagItems.size, result.size) assertTrue(result.containsAll(tagItems)) } + // 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 `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 `selecting a credential keeps only logins holding it`() { + val state = FilterState().with(FilterFacet.Credentials, setOf(CredentialType.Passkey)) + val result = useCase(state, logins, credentialAttributes) - assertEquals(2, result.size) - assertTrue(result.all { it.id in matching }) + assertEquals(listOf(passkeyLogin.id), result.map { it.id }) } @Test - fun `empty tagMatchingIds returns empty list`() { - val result = useCase(FilterState(), tagItems, noScores, emptySet()) - assertTrue(result.isEmpty()) + 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 `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) + 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 }) + } + + // 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(1, result.size) - assertEquals(tagItems[0].id, result.single().id) + assertEquals(setOf(passwordLogin.id, expiredCard.id), result.mapTo(mutableSetOf()) { it.id }) } -} \ 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..1cc289768 --- /dev/null +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveFilterResultUseCaseTest.kt @@ -0,0 +1,192 @@ +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 +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 +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 creditCardRepository = FakeCreditCardRepository() + private val sortUseCase = SortUseCase() + + private val useCase = ObserveFilterResultUseCase( + itemRepository = itemRepository, + loginRepository = loginRepository, + filterUseCase = FilterUseCase(sortUseCase), + observeCardExpiryStatuses = ObserveCardExpiryStatusesUseCase(creditCardRepository), + availableFacetValues = AvailableFacetValuesUseCase(), + ) + + private fun login( + name: String, + id: ItemId = newItemId(), + vault: VaultId = newVaultId(), + tags: Set = emptySet(), + passwordCredential: PasswordCredential? = null, + passkeys: Set = emptySet(), + ) = Login( + id = id, + name = name, + username = null, + domainInfos = emptySet(), + passwordCredential = passwordCredential, + totp = null, + passkeys = passkeys, + 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] + ) + } + + @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/ItemListViewModelTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt index 7da35ae85..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 @@ -14,13 +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 @@ -39,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 /** @@ -68,7 +75,6 @@ class ItemListViewModelTest { enableSelection = enableSelection, restrictedItemType = null, itemRepository = itemRepository, - filterUseCase = FilterUseCase(sortUseCase), rankSearchResults = RankSearchResultsUseCase(sortUseCase), observeAllTags = ObserveAllTagsSortedUseCase(itemRepository, sortUseCase), observeVaultsAndSelection = ObserveVaultsAndSelectionUseCase( @@ -76,8 +82,13 @@ class ItemListViewModelTest { vaultContextRepository = vaultContextRepository, sortUseCase = sortUseCase, ), - observeCardExpiryStatuses = ObserveCardExpiryStatusesUseCase(creditCardRepository), - loginRepository = loginRepository, + observeFilterResult = ObserveFilterResultUseCase( + itemRepository = itemRepository, + loginRepository = loginRepository, + filterUseCase = FilterUseCase(sortUseCase), + observeCardExpiryStatuses = ObserveCardExpiryStatusesUseCase(creditCardRepository), + availableFacetValues = AvailableFacetValuesUseCase(), + ), ) private fun login( @@ -85,6 +96,7 @@ class ItemListViewModelTest { id: ItemId = newItemId(), vault: VaultId = vaultId, pinned: Boolean = false, + tags: Set = emptySet(), ) = Login( id = id, name = name, @@ -98,6 +110,7 @@ class ItemListViewModelTest { vaultId = vault, keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), timestamp = Timestamp(), + tags = tags, ) private suspend fun storedIds(): Set = @@ -426,6 +439,98 @@ 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.Toggled(FilterFacet.Pinned, true)) + 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.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() @@ -463,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..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,92 +1,266 @@ 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.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.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, + isVisible = false, + ) + + private fun scoresAvailable(vararg scores: PasswordScore): FacetSelections = + defaultFacets.with(FilterFacet.PasswordScores, scores.toSet()) + + private fun FilterBottomSheetState.scoreChips() = + 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 include only tags carried by a visible item, in allTags order`() { - val a = TestLiteItem("A") - val b = TestLiteItem("B") + 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, + retained = FacetSelections.None, + isVisible = false, + ) + + 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), + ) + + assertEquals( + listOf(PasswordScore.Strong to false, PasswordScore.Weak to true), + sheet.scoreChips(), + ) + } - val options = listOf(a, b).toAvailableFilterOptions( - passwordScores = noScores, - tagsByItem = mapOf( - a.id to setOf(tag("Bank")), - b.id to setOf(tag("Work")), + @Test + 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) ), - // "Personal" belongs to no visible item -> must be excluded. - allTags = listOf(tag("Bank"), tag("Personal"), tag("Work")), ) - // Order follows the (already sorted) allTags list. - assertEquals(listOf(tag("Bank"), tag("Work")), options.tags.toList()) + assertEquals( + listOf( + PasswordScore.Excellent to false, + PasswordScore.Weak to false, + PasswordScore.Ridiculous to false, + ), + sheet.scoreChips(), + ) } @Test - fun `tags belonging only to items absent from the list are excluded`() { - val a = TestLiteItem("A") - val hidden = TestLiteItem("Hidden") + fun `a deselected score no item carries loses its chip once nothing retains it`() { + val sheet = sheetFor(FilterState(), scoresAvailable(PasswordScore.Strong)) - val options = listOf(a).toAvailableFilterOptions( - passwordScores = noScores, - tagsByItem = mapOf( - a.id to setOf(tag("Bank")), - hidden.id to setOf(tag("Secret")), + 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) ), - allTags = listOf(tag("Bank"), tag("Secret")), ) - assertEquals(setOf(tag("Bank")), options.tags) + assertEquals(listOf(PasswordScore.Weak to true), 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 `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, ) + } - assertTrue(options.tags.isEmpty()) + @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 `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 `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, + retained = FacetSelections.None, + isVisible = false, + ) + + assertEquals( + listOf( + FilterChipState(value = tag("Work"), selected = false), + FilterChipState(value = tag("Bank"), selected = true), + ), + sheet.itemSection?.tagChips, ) + } - val sheet = FilterState(selectedTags = setOf(tag("Bank"))) - .toBottomSheetState(available, restrictedItemType = null) + @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), + ), + ) - 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(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) + } + + @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) } }