diff --git a/androidApp/src/main/java/com/artemchep/keyguard/Main.kt b/androidApp/src/main/java/com/artemchep/keyguard/Main.kt
index 7bec3291..2ce3e97c 100644
--- a/androidApp/src/main/java/com/artemchep/keyguard/Main.kt
+++ b/androidApp/src/main/java/com/artemchep/keyguard/Main.kt
@@ -1,11 +1,16 @@
package com.artemchep.keyguard
import android.content.Context
+import android.content.Intent
import androidx.core.content.ContextCompat
+import androidx.core.content.pm.ShortcutInfoCompat
+import androidx.core.content.pm.ShortcutManagerCompat
+import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.artemchep.bindin.bindBlock
import com.artemchep.keyguard.android.BaseApp
+import com.artemchep.keyguard.android.MainActivity
import com.artemchep.keyguard.android.downloader.journal.DownloadRepository
import com.artemchep.keyguard.android.downloader.worker.AttachmentDownloadAllWorker
import com.artemchep.keyguard.android.passkeysModule
@@ -22,6 +27,7 @@ import com.artemchep.keyguard.core.session.diFingerprintRepositoryModule
import com.artemchep.keyguard.common.model.MasterSession
import com.artemchep.keyguard.common.service.vault.KeyReadWriteRepository
import com.artemchep.keyguard.common.model.PersistedSession
+import com.artemchep.keyguard.common.service.filter.GetCipherFilters
import com.artemchep.keyguard.feature.favicon.Favicon
import com.artemchep.keyguard.feature.localization.textResource
import com.artemchep.keyguard.platform.LeContext
@@ -223,5 +229,53 @@ class Main : BaseApp(), DIAware {
}
.launchIn(this)
}
+
+ // shortcuts
+ ProcessLifecycleOwner.get().lifecycleScope.launch {
+ getVaultSession()
+ .flatMapLatest { session ->
+ when (session) {
+ is MasterSession.Key -> {
+ val getCipherFilters: GetCipherFilters = session.di.direct.instance()
+ getCipherFilters()
+ }
+
+ is MasterSession.Empty -> emptyFlow()
+ }
+ }
+ .onEach { filters ->
+ val dynamicShortcutsIdsToRemove = kotlin.run {
+ val oldDynamicShortcutsIds =
+ ShortcutManagerCompat.getDynamicShortcuts(this@Main)
+ .map { it.id }
+ .toSet()
+ val newDynamicShortcutsIds = filters
+ .map { it.id }
+ .toSet()
+ oldDynamicShortcutsIds - newDynamicShortcutsIds
+ }
+ if (dynamicShortcutsIdsToRemove.isNotEmpty()) {
+ val ids = dynamicShortcutsIdsToRemove.toList()
+ ShortcutManagerCompat.removeDynamicShortcuts(this@Main, ids)
+ }
+
+ val shortcuts = filters
+ .map {
+ val intent = MainActivity.getIntent(this@Main).apply {
+ action = Intent.ACTION_VIEW
+ putExtra("customFilter", it.id)
+ }
+ val icon = IconCompat.createWithResource(this@Main, com.artemchep.keyguard.common.R.drawable.ic_shortcut_keyguard)
+ ShortcutInfoCompat.Builder(this@Main, it.id)
+ .setIcon(icon)
+ .setShortLabel(it.name)
+ .setIntent(intent)
+ .addCapabilityBinding("actions.intent.OPEN_APP_FEATURE")
+ .build()
+ }
+ ShortcutManagerCompat.addDynamicShortcuts(this@Main, shortcuts)
+ }
+ .launchIn(this)
+ }
}
}
diff --git a/common/build.gradle.kts b/common/build.gradle.kts
index fbcc3022..1776ec0b 100644
--- a/common/build.gradle.kts
+++ b/common/build.gradle.kts
@@ -127,6 +127,7 @@ kotlin {
api(libs.androidx.browser)
api(libs.androidx.core.ktx)
api(libs.androidx.core.splashscreen)
+ api(libs.androidx.core.shortcuts)
api(libs.androidx.credentials)
api(libs.androidx.datastore)
api(libs.androidx.lifecycle.livedata.ktx)
diff --git a/common/src/androidMain/res/drawable/ic_lock_outline.xml b/common/src/androidMain/res/drawable/ic_lock_outline.xml
index 82e002d1..d6b5e9c6 100644
--- a/common/src/androidMain/res/drawable/ic_lock_outline.xml
+++ b/common/src/androidMain/res/drawable/ic_lock_outline.xml
@@ -5,6 +5,6 @@
android:viewportWidth="24"
android:viewportHeight="24">
\ No newline at end of file
diff --git a/common/src/androidMain/res/drawable/ic_shortcut_keyguard.xml b/common/src/androidMain/res/drawable/ic_shortcut_keyguard.xml
new file mode 100644
index 00000000..bdc6687e
--- /dev/null
+++ b/common/src/androidMain/res/drawable/ic_shortcut_keyguard.xml
@@ -0,0 +1,13 @@
+
+
+ -
+
+
+
+
+ -
+
+
+
diff --git a/common/src/androidMain/res/xml/shortcuts.xml b/common/src/androidMain/res/xml/shortcuts.xml
index 364daafb..b9822dd8 100644
--- a/common/src/androidMain/res/xml/shortcuts.xml
+++ b/common/src/androidMain/res/xml/shortcuts.xml
@@ -1,20 +1,2 @@
-
-
-
-
-
-
-
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/DCipherFilter.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/DCipherFilter.kt
new file mode 100644
index 00000000..c0a00f21
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/DCipherFilter.kt
@@ -0,0 +1,25 @@
+package com.artemchep.keyguard.common.model
+
+import androidx.compose.ui.graphics.vector.ImageVector
+import com.artemchep.keyguard.ui.icons.generateAccentColors
+import kotlinx.datetime.Instant
+
+data class DCipherFilter(
+ val idRaw: Long,
+ val icon: ImageVector?,
+ val name: String,
+ val filter: Map>,
+ val updatedDate: Instant,
+ val createdDate: Instant,
+) : Comparable {
+ val id = idRaw.toString()
+
+ val accentColor = run {
+ val colors = generateAccentColors(name)
+ colors
+ }
+
+ override fun compareTo(other: DCipherFilter): Int {
+ return name.compareTo(other.name, ignoreCase = true)
+ }
+}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/DFilter.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/DFilter.kt
index a71e15ee..59343848 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/DFilter.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/DFilter.kt
@@ -1,5 +1,9 @@
package com.artemchep.keyguard.common.model
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Key
+import androidx.compose.material.icons.outlined.Password
+import androidx.compose.ui.graphics.vector.ImageVector
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.partially1
@@ -22,11 +26,29 @@ import com.artemchep.keyguard.common.usecase.CipherUnsecureUrlCheck
import com.artemchep.keyguard.common.usecase.CipherUrlDuplicateCheck
import com.artemchep.keyguard.core.store.bitwarden.exists
import com.artemchep.keyguard.feature.crashlytics.crashlyticsTap
+import com.artemchep.keyguard.feature.home.vault.component.obscurePassword
+import com.artemchep.keyguard.feature.localization.TextHolder
import com.artemchep.keyguard.provider.bitwarden.entity.HibpBreachGroup
+import com.artemchep.keyguard.res.Res
+import com.artemchep.keyguard.ui.icons.KeyguardAttachment
+import com.artemchep.keyguard.ui.icons.KeyguardAuthReprompt
+import com.artemchep.keyguard.ui.icons.KeyguardDuplicateWebsites
+import com.artemchep.keyguard.ui.icons.KeyguardExpiringItems
+import com.artemchep.keyguard.ui.icons.KeyguardFailedItems
+import com.artemchep.keyguard.ui.icons.KeyguardIgnoredAlerts
+import com.artemchep.keyguard.ui.icons.KeyguardIncompleteItems
+import com.artemchep.keyguard.ui.icons.KeyguardPasskey
+import com.artemchep.keyguard.ui.icons.KeyguardPendingSyncItems
+import com.artemchep.keyguard.ui.icons.KeyguardPwnedPassword
+import com.artemchep.keyguard.ui.icons.KeyguardPwnedWebsites
+import com.artemchep.keyguard.ui.icons.KeyguardReusedPassword
+import com.artemchep.keyguard.ui.icons.KeyguardTwoFa
+import com.artemchep.keyguard.ui.icons.KeyguardUnsecureWebsites
import io.ktor.http.Url
import kotlinx.datetime.Clock
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
import org.kodein.di.DirectDI
import org.kodein.di.instance
import kotlin.collections.Collection
@@ -89,7 +111,24 @@ sealed interface DFilter {
_findOne(f, target, predicate)
}
- else -> Either.Left(Unit)
+ else -> kotlin.run {
+ filter.filters.forEach { f ->
+ val r = _findOne(f, target, predicate)
+ r.fold(
+ ifLeft = {
+ return@run r
+ },
+ ifRight = {
+ if (it != null) {
+ return@run Either.Left(Unit)
+ }
+
+ it
+ },
+ )
+ }
+ Either.Right(null)
+ }
}
}
@@ -128,6 +167,52 @@ sealed interface DFilter {
}
}
}
+
+ inline fun findAny(
+ filter: DFilter,
+ noinline predicate: (T) -> Boolean = { true },
+ ): T? = findAny(
+ filter = filter,
+ target = T::class.java,
+ predicate = predicate,
+ )
+
+ fun findAny(
+ filter: DFilter,
+ target: Class,
+ predicate: (T) -> Boolean = { true },
+ ): T? = _findAny(
+ filter = filter,
+ target = target,
+ predicate = predicate,
+ )
+
+ private fun _findAny(
+ filter: DFilter,
+ target: Class,
+ predicate: (T) -> Boolean = { true },
+ ): T? = when (filter) {
+ is Or<*> -> filter
+ .filters
+ .firstNotNullOfOrNull { f ->
+ _findAny(f, target, predicate)
+ }
+
+ is And<*> -> filter
+ .filters
+ .firstNotNullOfOrNull { f ->
+ _findAny(f, target, predicate)
+ }
+
+ else -> {
+ if (filter.javaClass == target) {
+ val f = filter as T
+ f.takeIf(predicate)
+ } else {
+ null
+ }
+ }
+ }
}
suspend fun prepare(
@@ -145,6 +230,21 @@ sealed interface DFilter {
val key: String
}
+ @Serializable
+ sealed interface PrimitiveSpecial : Primitive {
+ }
+
+ @Serializable
+ sealed interface PrimitiveSimple : Primitive {
+ data class Content(
+ val title: TextHolder,
+ val icon: ImageVector? = null,
+ )
+
+ @Transient
+ val content: Content
+ }
+
@Serializable
@SerialName("or")
data class Or(
@@ -258,7 +358,8 @@ sealed interface DFilter {
data class ById(
val id: String?,
val what: What,
- ) : Primitive {
+ ) : PrimitiveSpecial {
+ @Transient
override val key: String = "$id|$what"
@Serializable
@@ -328,10 +429,19 @@ sealed interface DFilter {
@Serializable
@SerialName("by_type")
data class ByType(
+ @SerialName("cipherType")
val type: DSecret.Type,
- ) : Primitive {
+ ) : PrimitiveSimple {
+ @Transient
override val key: String = "$type"
+ @Transient
+ override val content = PrimitiveSimple.Content(
+ title = type.titleH()
+ .let(TextHolder::Res),
+ icon = type.iconImageVector(),
+ )
+
override suspend fun prepare(
directDI: DirectDI,
ciphers: List,
@@ -344,9 +454,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_otp")
- data object ByOtp : Primitive {
+ data object ByOtp : PrimitiveSimple {
+ @Transient
override val key: String = "otp"
+ @Transient
+ override val content = PrimitiveSimple.Content(
+ title = Res.strings.one_time_password
+ .let(TextHolder::Res),
+ icon = Icons.Outlined.KeyguardTwoFa,
+ )
+
override suspend fun prepare(
directDI: DirectDI,
ciphers: List,
@@ -359,9 +477,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_attachments")
- data object ByAttachments : Primitive {
+ data object ByAttachments : PrimitiveSimple {
+ @Transient
override val key: String = "attachments"
+ @Transient
+ override val content = PrimitiveSimple.Content(
+ title = Res.strings.attachments
+ .let(TextHolder::Res),
+ icon = Icons.Outlined.KeyguardAttachment,
+ )
+
override suspend fun prepare(
directDI: DirectDI,
ciphers: List,
@@ -374,9 +500,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_passkeys")
- data object ByPasskeys : Primitive {
+ data object ByPasskeys : PrimitiveSimple {
+ @Transient
override val key: String = "passkeys"
+ @Transient
+ override val content = PrimitiveSimple.Content(
+ title = Res.strings.passkeys
+ .let(TextHolder::Res),
+ icon = Icons.Outlined.Key,
+ )
+
override suspend fun prepare(
directDI: DirectDI,
ciphers: List,
@@ -391,9 +525,17 @@ sealed interface DFilter {
@SerialName("by_pwd_value")
data class ByPasswordValue(
val value: String?,
- ) : Primitive {
+ ) : PrimitiveSimple {
+ @Transient
override val key: String = "pwd_value|$value"
+ @Transient
+ override val content = PrimitiveSimple.Content(
+ title = value?.let(::obscurePassword).orEmpty()
+ .let(TextHolder::Value),
+ icon = Icons.Outlined.Password,
+ )
+
override suspend fun prepare(
directDI: DirectDI,
ciphers: List,
@@ -408,8 +550,22 @@ sealed interface DFilter {
@SerialName("by_pwd_strength")
data class ByPasswordStrength(
val score: PasswordStrength.Score,
- ) : Primitive {
- override val key: String = "$score"
+ ) : PrimitiveSimple {
+ @Transient
+ override val key: String = "pwd_score|$score"
+
+ @Transient
+ override val content = PrimitiveSimple.Content(
+ title = when (score) {
+ PasswordStrength.Score.Weak -> Res.strings.passwords_weak_label
+ PasswordStrength.Score.Fair -> Res.strings.passwords_fair_label
+ PasswordStrength.Score.Good -> Res.strings.passwords_good_label
+ PasswordStrength.Score.Strong -> Res.strings.passwords_strong_label
+ PasswordStrength.Score.VeryStrong -> Res.strings.passwords_very_strong_label
+ }
+ .let(TextHolder::Res),
+ icon = Icons.Outlined.Password,
+ )
override suspend fun prepare(
directDI: DirectDI,
@@ -423,9 +579,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_pwd_duplicates")
- data object ByPasswordDuplicates : Primitive {
+ data object ByPasswordDuplicates : PrimitiveSimple {
+ @Transient
override val key: String = "pwd_duplicates"
+ @Transient
+ override val content = PrimitiveSimple.Content(
+ title = Res.strings.watchtower_item_reused_passwords_title
+ .let(TextHolder::Res),
+ icon = Icons.Outlined.KeyguardReusedPassword,
+ )
+
private data class DuplicatesState(
var duplicate: Int,
var ignored: Int,
@@ -497,9 +661,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_pwd_pwned")
- data object ByPasswordPwned : Primitive {
+ data object ByPasswordPwned : PrimitiveSimple {
+ @Transient
override val key: String = "pwd_pwned"
+ @Transient
+ override val content = PrimitiveSimple.Content(
+ title = Res.strings.watchtower_item_pwned_passwords_title
+ .let(TextHolder::Res),
+ icon = Icons.Outlined.KeyguardPwnedPassword,
+ )
+
override suspend fun prepare(
directDI: DirectDI,
ciphers: List,
@@ -565,9 +737,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_website_pwned")
- data object ByWebsitePwned : Primitive {
+ data object ByWebsitePwned : PrimitiveSimple {
+ @Transient
override val key: String = "website_pwned"
+ @Transient
+ override val content = PrimitiveSimple.Content(
+ title = Res.strings.watchtower_item_vulnerable_accounts_title
+ .let(TextHolder::Res),
+ icon = Icons.Outlined.KeyguardPwnedWebsites,
+ )
+
override suspend fun prepare(
directDI: DirectDI,
ciphers: List,
@@ -627,9 +807,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_incomplete")
- data object ByIncomplete : Primitive {
+ data object ByIncomplete : PrimitiveSimple {
+ @Transient
override val key: String = "incomplete"
+ @Transient
+ override val content = PrimitiveSimple.Content(
+ title = Res.strings.watchtower_item_incomplete_items_title
+ .let(TextHolder::Res),
+ icon = Icons.Outlined.KeyguardIncompleteItems,
+ )
+
override suspend fun prepare(
directDI: DirectDI,
ciphers: List,
@@ -676,9 +864,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_expiring")
- data object ByExpiring : Primitive {
+ data object ByExpiring : PrimitiveSimple {
+ @Transient
override val key: String = "expiring"
+ @Transient
+ override val content = PrimitiveSimple.Content(
+ title = Res.strings.watchtower_item_expiring_items_title
+ .let(TextHolder::Res),
+ icon = Icons.Outlined.KeyguardExpiringItems,
+ )
+
override suspend fun prepare(
directDI: DirectDI,
ciphers: List,
@@ -726,9 +922,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_unsecure_websites")
- data object ByUnsecureWebsites : Primitive {
+ data object ByUnsecureWebsites : PrimitiveSimple {
+ @Transient
override val key: String = "unsecure_websites"
+ @Transient
+ override val content = PrimitiveSimple.Content(
+ title = Res.strings.watchtower_item_unsecure_websites_title
+ .let(TextHolder::Res),
+ icon = Icons.Outlined.KeyguardUnsecureWebsites,
+ )
+
override suspend fun prepare(
directDI: DirectDI,
ciphers: List,
@@ -777,9 +981,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_tfa_websites")
- data object ByTfaWebsites : Primitive {
+ data object ByTfaWebsites : PrimitiveSimple {
+ @Transient
override val key: String = "tfa_websites"
+ @Transient
+ override val content = PrimitiveSimple.Content(
+ title = Res.strings.watchtower_item_inactive_2fa_title
+ .let(TextHolder::Res),
+ icon = Icons.Outlined.KeyguardTwoFa,
+ )
+
override suspend fun prepare(
directDI: DirectDI,
ciphers: List,
@@ -874,9 +1086,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_passkey_websites")
- data object ByPasskeyWebsites : Primitive {
+ data object ByPasskeyWebsites : PrimitiveSimple {
+ @Transient
override val key: String = "passkey_websites"
+ @Transient
+ override val content = PrimitiveSimple.Content(
+ title = Res.strings.watchtower_item_inactive_passkey_title
+ .let(TextHolder::Res),
+ icon = Icons.Outlined.KeyguardPasskey,
+ )
+
override suspend fun prepare(
directDI: DirectDI,
ciphers: List,
@@ -983,9 +1203,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_duplicate_websites")
- data object ByDuplicateWebsites : Primitive {
+ data object ByDuplicateWebsites : PrimitiveSimple {
+ @Transient
override val key: String = "duplicate_websites"
+ @Transient
+ override val content = PrimitiveSimple.Content(
+ title = Res.strings.watchtower_item_duplicate_websites_title
+ .let(TextHolder::Res),
+ icon = Icons.Outlined.KeyguardDuplicateWebsites,
+ )
+
override suspend fun prepare(
directDI: DirectDI,
ciphers: List,
@@ -1049,9 +1277,17 @@ sealed interface DFilter {
@SerialName("by_sync")
data class BySync(
val synced: Boolean,
- ) : Primitive {
+ ) : PrimitiveSimple {
+ @Transient
override val key: String = "$synced"
+ @Transient
+ override val content = PrimitiveSimple.Content(
+ title = Res.strings.filter_pending_items
+ .let(TextHolder::Res),
+ icon = Icons.Outlined.KeyguardPendingSyncItems,
+ )
+
override suspend fun prepare(
directDI: DirectDI,
ciphers: List,
@@ -1075,9 +1311,17 @@ sealed interface DFilter {
@SerialName("by_repromt")
data class ByReprompt(
val reprompt: Boolean,
- ) : Primitive {
+ ) : PrimitiveSimple {
+ @Transient
override val key: String = "$reprompt"
+ @Transient
+ override val content = PrimitiveSimple.Content(
+ title = Res.strings.filter_auth_reprompt_items
+ .let(TextHolder::Res),
+ icon = Icons.Outlined.KeyguardAuthReprompt,
+ )
+
override suspend fun prepare(
directDI: DirectDI,
ciphers: List,
@@ -1092,9 +1336,17 @@ sealed interface DFilter {
@SerialName("by_error")
data class ByError(
val error: Boolean,
- ) : Primitive {
+ ) : PrimitiveSimple {
+ @Transient
override val key: String = "$error"
+ @Transient
+ override val content = PrimitiveSimple.Content(
+ title = Res.strings.filter_failed_items
+ .let(TextHolder::Res),
+ icon = Icons.Outlined.KeyguardFailedItems,
+ )
+
override suspend fun prepare(
directDI: DirectDI,
ciphers: List,
@@ -1116,9 +1368,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_ignored_alerts")
- data object ByIgnoredAlerts : Primitive {
+ data object ByIgnoredAlerts : PrimitiveSimple {
+ @Transient
override val key: String = "ignored_alerts"
+ @Transient
+ override val content = PrimitiveSimple.Content(
+ title = Res.strings.ignored_alerts
+ .let(TextHolder::Res),
+ icon = Icons.Outlined.KeyguardIgnoredAlerts,
+ )
+
override suspend fun prepare(
directDI: DirectDI,
ciphers: List,
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/AddCipherFilter.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/AddCipherFilter.kt
new file mode 100644
index 00000000..08d9075d
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/AddCipherFilter.kt
@@ -0,0 +1,6 @@
+package com.artemchep.keyguard.common.service.filter
+
+import com.artemchep.keyguard.common.io.IO
+import com.artemchep.keyguard.common.service.filter.model.AddCipherFilterRequest
+
+interface AddCipherFilter : (AddCipherFilterRequest) -> IO
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/GetCipherFilters.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/GetCipherFilters.kt
new file mode 100644
index 00000000..db3b9e4c
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/GetCipherFilters.kt
@@ -0,0 +1,6 @@
+package com.artemchep.keyguard.common.service.filter
+
+import com.artemchep.keyguard.common.model.DCipherFilter
+import kotlinx.coroutines.flow.Flow
+
+interface GetCipherFilters : () -> Flow>
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/RemoveCipherFilterById.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/RemoveCipherFilterById.kt
new file mode 100644
index 00000000..a34115ab
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/RemoveCipherFilterById.kt
@@ -0,0 +1,7 @@
+package com.artemchep.keyguard.common.service.filter
+
+import com.artemchep.keyguard.common.io.IO
+
+interface RemoveCipherFilterById : (
+ Set,
+) -> IO
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/RenameCipherFilter.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/RenameCipherFilter.kt
new file mode 100644
index 00000000..c54f3dde
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/RenameCipherFilter.kt
@@ -0,0 +1,6 @@
+package com.artemchep.keyguard.common.service.filter
+
+import com.artemchep.keyguard.common.io.IO
+import com.artemchep.keyguard.common.service.filter.model.RenameCipherFilterRequest
+
+interface RenameCipherFilter : (RenameCipherFilterRequest) -> IO
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/entity/FilterEntity.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/entity/FilterEntity.kt
new file mode 100644
index 00000000..894ebf1d
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/entity/FilterEntity.kt
@@ -0,0 +1,9 @@
+package com.artemchep.keyguard.common.service.filter.entity
+
+import com.artemchep.keyguard.common.model.DFilter
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class FilterEntity(
+ val state: Map>,
+)
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/impl/AddCipherFilterImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/impl/AddCipherFilterImpl.kt
new file mode 100644
index 00000000..3c30037a
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/impl/AddCipherFilterImpl.kt
@@ -0,0 +1,24 @@
+package com.artemchep.keyguard.common.service.filter.impl
+
+import com.artemchep.keyguard.common.io.IO
+import com.artemchep.keyguard.common.io.ioEffect
+import com.artemchep.keyguard.common.service.filter.AddCipherFilter
+import com.artemchep.keyguard.common.service.filter.model.AddCipherFilterRequest
+import com.artemchep.keyguard.common.service.filter.repo.CipherFilterRepository
+import org.kodein.di.DirectDI
+import org.kodein.di.instance
+
+class AddCipherFilterImpl(
+ private val cipherFilterRepository: CipherFilterRepository,
+) : AddCipherFilter {
+ constructor(
+ directDI: DirectDI,
+ ) : this(
+ cipherFilterRepository = directDI.instance(),
+ )
+
+ override fun invoke(
+ request: AddCipherFilterRequest,
+ ): IO = cipherFilterRepository
+ .post(data = request)
+}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/impl/GetCipherFiltersImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/impl/GetCipherFiltersImpl.kt
new file mode 100644
index 00000000..f3454eee
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/impl/GetCipherFiltersImpl.kt
@@ -0,0 +1,23 @@
+package com.artemchep.keyguard.common.service.filter.impl
+
+import com.artemchep.keyguard.common.model.DCipherFilter
+import com.artemchep.keyguard.common.model.DFilter
+import com.artemchep.keyguard.common.service.filter.GetCipherFilters
+import com.artemchep.keyguard.common.service.filter.repo.CipherFilterRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.datetime.Clock
+import org.kodein.di.DirectDI
+import org.kodein.di.instance
+
+class GetCipherFiltersImpl(
+ private val cipherFilterRepository: CipherFilterRepository,
+) : GetCipherFilters {
+ constructor(
+ directDI: DirectDI,
+ ) : this(
+ cipherFilterRepository = directDI.instance(),
+ )
+
+ override fun invoke(): Flow> = cipherFilterRepository.get()
+}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/impl/RemoveCipherFilterByIdImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/impl/RemoveCipherFilterByIdImpl.kt
new file mode 100644
index 00000000..c0b00557
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/impl/RemoveCipherFilterByIdImpl.kt
@@ -0,0 +1,22 @@
+package com.artemchep.keyguard.common.service.filter.impl
+
+import com.artemchep.keyguard.common.io.IO
+import com.artemchep.keyguard.common.service.filter.RemoveCipherFilterById
+import com.artemchep.keyguard.common.service.filter.repo.CipherFilterRepository
+import org.kodein.di.DirectDI
+import org.kodein.di.instance
+
+class RemoveCipherFilterByIdImpl(
+ private val cipherFilterRepository: CipherFilterRepository,
+) : RemoveCipherFilterById {
+ constructor(
+ directDI: DirectDI,
+ ) : this(
+ cipherFilterRepository = directDI.instance(),
+ )
+
+ override fun invoke(
+ ids: Set,
+ ): IO = cipherFilterRepository
+ .removeByIds(ids)
+}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/impl/RenameCipherFilterImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/impl/RenameCipherFilterImpl.kt
new file mode 100644
index 00000000..3ca7c725
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/impl/RenameCipherFilterImpl.kt
@@ -0,0 +1,29 @@
+package com.artemchep.keyguard.common.service.filter.impl
+
+import com.artemchep.keyguard.common.io.bind
+import com.artemchep.keyguard.common.io.ioEffect
+import com.artemchep.keyguard.common.service.filter.RenameCipherFilter
+import com.artemchep.keyguard.common.service.filter.model.RenameCipherFilterRequest
+import com.artemchep.keyguard.common.service.filter.repo.CipherFilterRepository
+import org.kodein.di.DirectDI
+import org.kodein.di.instance
+
+class RenameCipherFilterImpl(
+ private val cipherFilterRepository: CipherFilterRepository,
+) : RenameCipherFilter {
+ constructor(directDI: DirectDI) : this(
+ cipherFilterRepository = directDI.instance(),
+ )
+
+ override fun invoke(
+ model: RenameCipherFilterRequest,
+ ) = ioEffect {
+ val name = model.name
+ cipherFilterRepository
+ .patch(
+ id = model.id,
+ name = name,
+ )
+ .bind()
+ }
+}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/model/AddCipherFilterRequest.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/model/AddCipherFilterRequest.kt
new file mode 100644
index 00000000..e75e477c
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/model/AddCipherFilterRequest.kt
@@ -0,0 +1,11 @@
+package com.artemchep.keyguard.common.service.filter.model
+
+import com.artemchep.keyguard.common.model.DFilter
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+
+data class AddCipherFilterRequest(
+ val now: Instant = Clock.System.now(),
+ val name: String,
+ val filter: Map>,
+)
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/model/RenameCipherFilterRequest.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/model/RenameCipherFilterRequest.kt
new file mode 100644
index 00000000..49068456
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/model/RenameCipherFilterRequest.kt
@@ -0,0 +1,6 @@
+package com.artemchep.keyguard.common.service.filter.model
+
+data class RenameCipherFilterRequest(
+ val id: Long,
+ val name: String,
+)
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/repo/CipherFilterRepository.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/repo/CipherFilterRepository.kt
new file mode 100644
index 00000000..852884ff
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/repo/CipherFilterRepository.kt
@@ -0,0 +1,25 @@
+package com.artemchep.keyguard.common.service.filter.repo
+
+import com.artemchep.keyguard.common.io.IO
+import com.artemchep.keyguard.common.model.DCipherFilter
+import com.artemchep.keyguard.common.service.filter.model.AddCipherFilterRequest
+import kotlinx.coroutines.flow.Flow
+
+interface CipherFilterRepository {
+ fun get(): Flow>
+
+ fun post(
+ data: AddCipherFilterRequest,
+ ): IO
+
+ fun patch(
+ id: Long,
+ name: String,
+ ): IO
+
+ fun removeAll(): IO
+
+ fun removeByIds(
+ ids: Set,
+ ): IO
+}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/repo/impl/CipherFilterRepositoryImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/repo/impl/CipherFilterRepositoryImpl.kt
new file mode 100644
index 00000000..d7c95aee
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/service/filter/repo/impl/CipherFilterRepositoryImpl.kt
@@ -0,0 +1,230 @@
+package com.artemchep.keyguard.common.service.filter.repo.impl
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.ui.graphics.vector.ImageVector
+import com.artemchep.keyguard.common.io.IO
+import com.artemchep.keyguard.common.io.effectMap
+import com.artemchep.keyguard.common.model.DCipherFilter
+import com.artemchep.keyguard.common.model.DFilter
+import com.artemchep.keyguard.common.model.DSecret
+import com.artemchep.keyguard.common.model.iconImageVector
+import com.artemchep.keyguard.common.service.filter.entity.FilterEntity
+import com.artemchep.keyguard.common.service.filter.model.AddCipherFilterRequest
+import com.artemchep.keyguard.common.service.filter.repo.CipherFilterRepository
+import com.artemchep.keyguard.common.service.state.impl.toJson
+import com.artemchep.keyguard.common.service.state.impl.toMap
+import com.artemchep.keyguard.common.util.sqldelight.flatMapQueryToList
+import com.artemchep.keyguard.core.store.DatabaseDispatcher
+import com.artemchep.keyguard.core.store.DatabaseManager
+import com.artemchep.keyguard.data.CipherFilter
+import com.artemchep.keyguard.data.CipherFilterQueries
+import com.artemchep.keyguard.feature.home.vault.screen.FilterSection
+import com.artemchep.keyguard.feature.localization.textResource
+import com.artemchep.keyguard.platform.LeContext
+import com.artemchep.keyguard.res.Res
+import com.artemchep.keyguard.ui.icons.KeyguardTwoFa
+import dev.icerock.moko.resources.StringResource
+import kotlinx.collections.immutable.toPersistentMap
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import org.kodein.di.DirectDI
+import org.kodein.di.instance
+
+class CipherFilterRepositoryImpl(
+ private val context: LeContext,
+ private val databaseManager: DatabaseManager,
+ private val json: Json,
+ private val dispatcher: CoroutineDispatcher,
+) : CipherFilterRepository {
+ companion object {
+ private const val TYPE_LOGIN = "login"
+ private const val TYPE_CARD = "card"
+ private const val TYPE_IDENTITY = "identity"
+ private const val TYPE_NOTE = "note"
+ private const val TYPE_OTP = "otp"
+ }
+
+ private interface FilterEntityMapper {
+ fun map(
+ context: LeContext,
+ entity: CipherFilter,
+ ): DCipherFilter
+ }
+
+ private class BaseFilterEntityMapper(
+ private val icon: ImageVector,
+ private val name: StringResource,
+ private val state: Map>,
+ ) : FilterEntityMapper {
+ override fun map(
+ context: LeContext,
+ entity: CipherFilter,
+ ): DCipherFilter {
+ val name = entity.name.takeIf { it.isNotBlank() }
+ ?: textResource(
+ res = name,
+ context = context,
+ )
+ return DCipherFilter(
+ idRaw = entity.id,
+ icon = icon,
+ name = name,
+ filter = state,
+ updatedDate = entity.updatedAt,
+ createdDate = entity.createdAt,
+ )
+ }
+ }
+
+ private val filterEntityMappers = mapOf(
+ TYPE_LOGIN to BaseFilterEntityMapper(
+ icon = DSecret.Type.Login.iconImageVector(),
+ name = Res.strings.cipher_type_login,
+ state = mapOf(
+ FilterSection.TYPE.id to setOf(
+ DFilter.ByType(DSecret.Type.Login),
+ ),
+ ),
+ ),
+ TYPE_CARD to BaseFilterEntityMapper(
+ icon = DSecret.Type.Card.iconImageVector(),
+ name = Res.strings.cipher_type_card,
+ state = mapOf(
+ FilterSection.TYPE.id to setOf(
+ DFilter.ByType(DSecret.Type.Card),
+ ),
+ ),
+ ),
+ TYPE_IDENTITY to BaseFilterEntityMapper(
+ icon = DSecret.Type.Identity.iconImageVector(),
+ name = Res.strings.cipher_type_identity,
+ state = mapOf(
+ FilterSection.TYPE.id to setOf(
+ DFilter.ByType(DSecret.Type.Identity),
+ ),
+ ),
+ ),
+ TYPE_NOTE to BaseFilterEntityMapper(
+ icon = DSecret.Type.SecureNote.iconImageVector(),
+ name = Res.strings.cipher_type_note,
+ state = mapOf(
+ FilterSection.TYPE.id to setOf(
+ DFilter.ByType(DSecret.Type.SecureNote),
+ ),
+ ),
+ ),
+ TYPE_OTP to BaseFilterEntityMapper(
+ icon = Icons.Outlined.KeyguardTwoFa,
+ name = Res.strings.one_time_password,
+ state = mapOf(
+ FilterSection.MISC.id to setOf(
+ DFilter.ByOtp,
+ ),
+ ),
+ ),
+ )
+
+ constructor(
+ directDI: DirectDI,
+ ) : this(
+ context = directDI.instance(),
+ databaseManager = directDI.instance(),
+ json = directDI.instance(),
+ dispatcher = directDI.instance(tag = DatabaseDispatcher),
+ )
+
+ override fun get(): Flow> =
+ daoEffect { dao ->
+ dao.get()
+ }
+ .flatMapQueryToList(dispatcher)
+ .map { entities ->
+ entities
+ .mapNotNull { entity ->
+ if (entity.type != null) {
+ val mapper = filterEntityMappers[entity.type]
+ // Unknown type, skip the entity.
+ ?: return@mapNotNull null
+ return@mapNotNull mapper.map(
+ context = context,
+ entity = entity,
+ )
+ }
+
+ val data = kotlin
+ .runCatching {
+ val el = json.decodeFromString(entity.data_)
+ el
+ }
+ .getOrElse {
+ FilterEntity(
+ state = emptyMap(),
+ )
+ }
+ DCipherFilter(
+ idRaw = entity.id,
+ icon = null,
+ name = entity.name,
+ filter = data.state,
+ updatedDate = entity.updatedAt,
+ createdDate = entity.createdAt,
+ )
+ }
+ .sorted()
+ }
+
+ override fun post(
+ data: AddCipherFilterRequest,
+ ): IO = daoEffect { dao ->
+ val data2 = kotlin.run {
+ val el = FilterEntity(
+ state = data.filter,
+ )
+ json.encodeToString(el)
+ }
+ dao.insert(
+ name = data.name,
+ data = data2,
+ updatedAt = data.now,
+ createdAt = data.now,
+ icon = null,
+ )
+ }
+
+ override fun patch(
+ id: Long,
+ name: String,
+ ): IO = daoEffect { dao ->
+ dao.rename(
+ id = id,
+ name = name,
+ )
+ }
+
+ override fun removeAll(): IO =
+ daoEffect { dao ->
+ dao.deleteAll()
+ }
+
+ override fun removeByIds(ids: Set): IO =
+ daoEffect { dao ->
+ dao.transaction {
+ ids.forEach { id ->
+ dao.deleteByIds(id)
+ }
+ }
+ }
+
+ private inline fun daoEffect(
+ crossinline block: suspend (CipherFilterQueries) -> T,
+ ): IO = databaseManager
+ .get()
+ .effectMap(dispatcher) { db ->
+ val dao = db.cipherFilterQueries
+ block(dao)
+ }
+}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/util/StringSurrogate.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/util/StringSurrogate.kt
new file mode 100644
index 00000000..3575ffe0
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/util/StringSurrogate.kt
@@ -0,0 +1,22 @@
+package com.artemchep.keyguard.common.util
+
+fun String.nextSymbol(index: Int = 0): String {
+ val a = getOrNull(index)
+ ?: return ""
+ if (a.isHighSurrogate() || a.isEmojiControl()) {
+ // Take the low surrogate pair too, as it formats
+ // human readable symbols.
+ return a.toString() + nextSymbol(index + 1)
+ }
+ // Check if the next symbol is not zero width space. If
+ // it is, then we have to take the symbol after it as well.
+ val b = getOrNull(index + 1)
+ if (b != null && b.isEmojiControl()) {
+ return a.toString() + b + nextSymbol(index + 2)
+ }
+ return a.toString()
+}
+
+private fun Char.isEmojiControl() =
+ this == '\u200d' ||
+ this == '\uFE0F'
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/core/session/usecase/SubDI.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/core/session/usecase/SubDI.kt
index 350e5d55..aaf27319 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/core/session/usecase/SubDI.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/core/session/usecase/SubDI.kt
@@ -5,6 +5,16 @@ import com.artemchep.keyguard.android.downloader.journal.CipherHistoryOpenedRepo
import com.artemchep.keyguard.android.downloader.journal.GeneratorHistoryRepository
import com.artemchep.keyguard.android.downloader.journal.GeneratorHistoryRepositoryImpl
import com.artemchep.keyguard.common.model.MasterKey
+import com.artemchep.keyguard.common.service.filter.AddCipherFilter
+import com.artemchep.keyguard.common.service.filter.GetCipherFilters
+import com.artemchep.keyguard.common.service.filter.RemoveCipherFilterById
+import com.artemchep.keyguard.common.service.filter.RenameCipherFilter
+import com.artemchep.keyguard.common.service.filter.impl.AddCipherFilterImpl
+import com.artemchep.keyguard.common.service.filter.impl.GetCipherFiltersImpl
+import com.artemchep.keyguard.common.service.filter.impl.RemoveCipherFilterByIdImpl
+import com.artemchep.keyguard.common.service.filter.impl.RenameCipherFilterImpl
+import com.artemchep.keyguard.common.service.filter.repo.CipherFilterRepository
+import com.artemchep.keyguard.common.service.filter.repo.impl.CipherFilterRepositoryImpl
import com.artemchep.keyguard.common.service.wordlist.repo.GeneratorWordlistRepository
import com.artemchep.keyguard.common.service.wordlist.repo.impl.GeneratorWordlistRepositoryImpl
import com.artemchep.keyguard.common.service.wordlist.repo.GeneratorWordlistWordRepository
@@ -483,6 +493,21 @@ fun DI.Builder.createSubDi2(
bindSingleton {
GeneratorWordlistWordRepositoryImpl(this)
}
+ bindSingleton {
+ CipherFilterRepositoryImpl(this)
+ }
+ bindSingleton {
+ AddCipherFilterImpl(this)
+ }
+ bindSingleton {
+ GetCipherFiltersImpl(this)
+ }
+ bindSingleton {
+ RemoveCipherFilterByIdImpl(this)
+ }
+ bindSingleton {
+ RenameCipherFilterImpl(this)
+ }
bindSingleton {
UrlOverrideRepositoryImpl(this)
}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/core/store/DatabaseManager.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/core/store/DatabaseManager.kt
index f1ca9cb0..72c48ff1 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/core/store/DatabaseManager.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/core/store/DatabaseManager.kt
@@ -19,6 +19,7 @@ import com.artemchep.keyguard.core.store.bitwarden.BitwardenOrganization
import com.artemchep.keyguard.core.store.bitwarden.BitwardenProfile
import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend
import com.artemchep.keyguard.core.store.bitwarden.BitwardenToken
+import com.artemchep.keyguard.data.CipherFilter
import com.artemchep.keyguard.data.CipherUsageHistory
import com.artemchep.keyguard.data.Database
import com.artemchep.keyguard.data.GeneratorWordlist
@@ -106,6 +107,10 @@ class DatabaseManagerImpl(
Database(
driver = driver,
cipherUsageHistoryAdapter = CipherUsageHistory.Adapter(InstantToLongAdapter),
+ cipherFilterAdapter = CipherFilter.Adapter(
+ updatedAtAdapter = InstantToLongAdapter,
+ createdAtAdapter = InstantToLongAdapter,
+ ),
generatorHistoryAdapter = GeneratorHistory.Adapter(InstantToLongAdapter),
generatorWordlistAdapter = GeneratorWordlist.Adapter(InstantToLongAdapter),
generatorEmailRelayAdapter = GeneratorEmailRelay.Adapter(InstantToLongAdapter),
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/attachments/AttachmentsScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/attachments/AttachmentsScreen.kt
index fae92fb1..76abc5f2 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/attachments/AttachmentsScreen.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/attachments/AttachmentsScreen.kt
@@ -104,6 +104,7 @@ fun AttachmentsScreen(
modifier = modifier,
items = state.getOrNull()?.filter?.items.orEmpty(),
onClear = state.getOrNull()?.filter?.onClear,
+ onSave = state.getOrNull()?.filter?.onSave,
)
},
scrollBehavior = scrollBehavior,
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/attachments/AttachmentsState.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/attachments/AttachmentsState.kt
index c2fce31c..81635668 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/attachments/AttachmentsState.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/attachments/AttachmentsState.kt
@@ -14,6 +14,7 @@ data class AttachmentsState(
data class Filter(
val items: List = emptyList(),
val onClear: (() -> Unit)? = null,
+ val onSave: (() -> Unit)? = null,
)
data class Stats(
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/attachments/AttachmentsStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/attachments/AttachmentsStateProducer.kt
index 102b0d07..ca48bafb 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/attachments/AttachmentsStateProducer.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/attachments/AttachmentsStateProducer.kt
@@ -136,7 +136,7 @@ fun produceAttachmentsScreenState(
) {
val selectionHandle = selectionHandle("selection")
- val filterResult = createFilter()
+ val filterResult = createFilter(directDI)
val ciphersFlow = getCiphers()
@@ -481,6 +481,7 @@ fun produceAttachmentsScreenState(
filter = AttachmentsState.Filter(
items = filterState.items,
onClear = filterState.onClear,
+ onSave = filterState.onSave,
),
stats = AttachmentsState.Stats(
totalAttachments = 0,
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportScreen.kt
index 8197c148..1e4c7e1e 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportScreen.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportScreen.kt
@@ -127,6 +127,7 @@ fun ExportScreenSkeleton(
modifier = modifier,
items = items,
onClear = null,
+ onSave = null,
)
},
) { modifier, tabletUi ->
@@ -185,6 +186,7 @@ fun ExportScreenOk(
modifier = modifier,
items = filter.items,
onClear = filter.onClear,
+ onSave = filter.onSave,
)
},
) { modifier, tabletUi ->
@@ -211,14 +213,14 @@ private fun ExportScreenFilterList(
modifier: Modifier = Modifier,
items: List,
onClear: (() -> Unit)?,
+ onSave: (() -> Unit)?,
) {
FilterScreen(
modifier = modifier,
count = null,
items = items,
onClear = onClear,
- actions = {
- },
+ onSave = onSave,
)
}
@@ -227,12 +229,14 @@ private fun ExportScreenFilterButton(
modifier: Modifier = Modifier,
items: List,
onClear: (() -> Unit)?,
+ onSave: (() -> Unit)?,
) {
FilterButton(
modifier = modifier,
count = null,
items = items,
onClear = onClear,
+ onSave = onSave,
)
}
@@ -277,6 +281,7 @@ private fun ExportScreen(
modifier = Modifier,
items = filter.items,
onClear = filter.onClear,
+ onSave = filter.onSave,
)
}
},
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportState.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportState.kt
index c1930871..3c9d8b0a 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportState.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportState.kt
@@ -26,6 +26,7 @@ data class ExportState(
data class Filter(
val items: List,
val onClear: (() -> Unit)? = null,
+ val onSave: (() -> Unit)? = null,
)
@Immutable
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportStateProducer.kt
index 609fa300..13e477eb 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportStateProducer.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/export/ExportStateProducer.kt
@@ -131,7 +131,7 @@ fun produceExportScreenState(
}
.shareIn(screenScope, SharingStarted.WhileSubscribed(), replay = 1)
- val filterResult = createFilter()
+ val filterResult = createFilter(directDI)
val filteredCiphersFlow = ciphersFlow
.map {
@@ -192,6 +192,7 @@ fun produceExportScreenState(
ExportState.Filter(
items = filterState.items,
onClear = filterState.onClear,
+ onSave = filterState.onSave,
)
}
.stateIn(screenScope)
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/CipherFiltersRoute.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/CipherFiltersRoute.kt
new file mode 100644
index 00000000..897d5a3a
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/CipherFiltersRoute.kt
@@ -0,0 +1,47 @@
+package com.artemchep.keyguard.feature.filter
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.FilterAlt
+import androidx.compose.material.icons.outlined.FilterList
+import androidx.compose.runtime.Composable
+import com.artemchep.keyguard.feature.navigation.NavigationIntent
+import com.artemchep.keyguard.feature.navigation.Route
+import com.artemchep.keyguard.feature.navigation.state.TranslatorScope
+import com.artemchep.keyguard.res.Res
+import com.artemchep.keyguard.ui.FlatItemAction
+import com.artemchep.keyguard.ui.icons.ChevronIcon
+import com.artemchep.keyguard.ui.icons.KeyguardCipherFilter
+import com.artemchep.keyguard.ui.icons.iconSmall
+
+object CipherFiltersRoute : Route {
+ const val ROUTER_NAME = "cipher_filters"
+
+ fun actionOrNull(
+ translator: TranslatorScope,
+ navigate: (NavigationIntent) -> Unit,
+ ) = action(
+ translator = translator,
+ navigate = navigate,
+ )
+
+ fun action(
+ translator: TranslatorScope,
+ navigate: (NavigationIntent) -> Unit,
+ ) = FlatItemAction(
+ leading = iconSmall(Icons.Outlined.KeyguardCipherFilter),
+ title = translator.translate(Res.strings.customfilters_header_title),
+ trailing = {
+ ChevronIcon()
+ },
+ onClick = {
+ val route = CipherFiltersRoute
+ val intent = NavigationIntent.NavigateToRoute(route)
+ navigate(intent)
+ },
+ )
+
+ @Composable
+ override fun Content() {
+ CipherFiltersScreen()
+ }
+}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/CipherFiltersScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/CipherFiltersScreen.kt
new file mode 100644
index 00000000..98924659
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/CipherFiltersScreen.kt
@@ -0,0 +1,20 @@
+package com.artemchep.keyguard.feature.filter
+
+import androidx.compose.runtime.Composable
+import com.artemchep.keyguard.feature.filter.list.CipherFiltersListRoute
+import com.artemchep.keyguard.feature.navigation.NavigationRouter
+import com.artemchep.keyguard.feature.twopane.TwoPaneNavigationContent
+import com.artemchep.keyguard.ui.screenMaxWidthCompact
+
+@Composable
+fun CipherFiltersScreen() {
+ NavigationRouter(
+ id = CipherFiltersRoute.ROUTER_NAME,
+ initial = CipherFiltersListRoute,
+ ) { backStack ->
+ TwoPaneNavigationContent(
+ backStack,
+ detailPaneMaxWidth = screenMaxWidthCompact,
+ )
+ }
+}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/list/CipherFiltersListRoute.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/list/CipherFiltersListRoute.kt
new file mode 100644
index 00000000..f59a503b
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/list/CipherFiltersListRoute.kt
@@ -0,0 +1,11 @@
+package com.artemchep.keyguard.feature.filter.list
+
+import androidx.compose.runtime.Composable
+import com.artemchep.keyguard.feature.navigation.Route
+
+object CipherFiltersListRoute : Route {
+ @Composable
+ override fun Content() {
+ CipherFiltersListScreen()
+ }
+}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/list/CipherFiltersListScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/list/CipherFiltersListScreen.kt
new file mode 100644
index 00000000..0b14c90f
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/list/CipherFiltersListScreen.kt
@@ -0,0 +1,380 @@
+package com.artemchep.keyguard.feature.filter.list
+
+import androidx.compose.animation.Crossfade
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material.pullrefresh.pullRefresh
+import androidx.compose.material.pullrefresh.rememberPullRefreshState
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.unit.dp
+import com.artemchep.keyguard.common.model.Loadable
+import com.artemchep.keyguard.common.model.flatMap
+import com.artemchep.keyguard.common.model.getOrNull
+import com.artemchep.keyguard.feature.EmptySearchView
+import com.artemchep.keyguard.feature.ErrorView
+import com.artemchep.keyguard.feature.filter.view.CipherFilterViewFullRoute
+import com.artemchep.keyguard.feature.generator.wordlist.view.WordlistViewRoute
+import com.artemchep.keyguard.feature.home.vault.component.SearchTextField
+import com.artemchep.keyguard.feature.home.vault.component.rememberSecretAccentColor
+import com.artemchep.keyguard.feature.navigation.NavigationIcon
+import com.artemchep.keyguard.feature.navigation.navigationNextEntryOrNull
+import com.artemchep.keyguard.feature.twopane.LocalHasDetailPane
+import com.artemchep.keyguard.platform.CurrentPlatform
+import com.artemchep.keyguard.platform.util.hasDynamicShortcuts
+import com.artemchep.keyguard.res.Res
+import com.artemchep.keyguard.ui.AvatarBuilder
+import com.artemchep.keyguard.ui.DefaultProgressBar
+import com.artemchep.keyguard.ui.DefaultSelection
+import com.artemchep.keyguard.ui.FlatItem
+import com.artemchep.keyguard.ui.MediumEmphasisAlpha
+import com.artemchep.keyguard.ui.ScaffoldLazyColumn
+import com.artemchep.keyguard.ui.focus.FocusRequester2
+import com.artemchep.keyguard.ui.focus.focusRequester2
+import com.artemchep.keyguard.ui.icons.ChevronIcon
+import com.artemchep.keyguard.ui.pulltosearch.PullToSearch
+import com.artemchep.keyguard.ui.skeleton.SkeletonItem
+import com.artemchep.keyguard.ui.theme.Dimens
+import com.artemchep.keyguard.ui.theme.combineAlpha
+import com.artemchep.keyguard.ui.theme.selectedContainer
+import com.artemchep.keyguard.ui.toolbar.CustomToolbar
+import com.artemchep.keyguard.ui.toolbar.content.CustomToolbarContent
+import dev.icerock.moko.resources.compose.stringResource
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.withIndex
+
+@Composable
+fun CipherFiltersListScreen(
+) {
+ val loadableState = produceCipherFiltersListState()
+ CipherFiltersListScreen(
+ loadableState = loadableState,
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
+@Composable
+fun CipherFiltersListScreen(
+ loadableState: Loadable,
+) {
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
+ val filterState = run {
+ val filterFlow = loadableState.getOrNull()?.filter
+ remember(filterFlow) {
+ filterFlow ?: MutableStateFlow(null)
+ }.collectAsState()
+ }
+
+ val listRevision =
+ loadableState.getOrNull()?.content?.getOrNull()?.getOrNull()?.revision
+ val listState = remember {
+ LazyListState(
+ firstVisibleItemIndex = 0,
+ firstVisibleItemScrollOffset = 0,
+ )
+ }
+
+ LaunchedEffect(listRevision) {
+ // TODO: How do you wait till the layout state start to represent
+ // the actual data?
+ val listSize =
+ loadableState.getOrNull()?.content?.getOrNull()?.getOrNull()?.items?.size
+ snapshotFlow { listState.layoutInfo.totalItemsCount }
+ .withIndex()
+ .filter {
+ it.index > 0 || it.value == listSize
+ }
+ .first()
+
+ listState.scrollToItem(0, 0)
+ }
+
+ val focusRequester = remember { FocusRequester2() }
+ // Auto focus the text field
+ // on launch.
+ LaunchedEffect(
+ focusRequester,
+ filterState,
+ ) {
+ snapshotFlow { filterState.value }
+ .first { it?.query?.onChange != null }
+ delay(100L)
+ focusRequester.requestFocus()
+ }
+
+ val pullRefreshState = rememberPullRefreshState(
+ refreshing = false,
+ onRefresh = {
+ focusRequester.requestFocus()
+ },
+ )
+ ScaffoldLazyColumn(
+ modifier = Modifier
+ .pullRefresh(pullRefreshState)
+ .nestedScroll(scrollBehavior.nestedScrollConnection),
+ topAppBarScrollBehavior = scrollBehavior,
+ topBar = {
+ CustomToolbar(
+ scrollBehavior = scrollBehavior,
+ ) {
+ Column {
+ CustomToolbarContent(
+ title = stringResource(Res.strings.customfilters_header_title),
+ icon = {
+ NavigationIcon()
+ },
+ )
+
+ val query = filterState.value?.query
+ val queryText = query?.state?.value.orEmpty()
+
+ val count = loadableState
+ .getOrNull()
+ ?.content
+ ?.getOrNull()
+ ?.getOrNull()
+ ?.items
+ ?.size
+ SearchTextField(
+ modifier = Modifier
+ .focusRequester2(focusRequester),
+ text = queryText,
+ placeholder = stringResource(Res.strings.customfilters_search_placeholder),
+ searchIcon = false,
+ count = count,
+ leading = {},
+ trailing = {},
+ onTextChange = query?.onChange,
+ onGoClick = null,
+ )
+ }
+ }
+ },
+ bottomBar = {
+ when (loadableState) {
+ is Loadable.Ok -> {
+ val selectionOrNull by loadableState.value.selection.collectAsState()
+ DefaultSelection(
+ state = selectionOrNull,
+ )
+ }
+ is Loadable.Loading -> {
+ // Do nothing
+ }
+ }
+ },
+ pullRefreshState = pullRefreshState,
+ overlay = {
+ val filterRevision = filterState.value?.revision
+ DefaultProgressBar(
+ visible = listRevision != null && filterRevision != null &&
+ listRevision != filterRevision,
+ )
+
+ PullToSearch(
+ modifier = Modifier
+ .padding(contentPadding.value),
+ pullRefreshState = pullRefreshState,
+ )
+ },
+ listState = listState,
+ ) {
+ val contentState = loadableState
+ .flatMap { it.content }
+ when (contentState) {
+ is Loadable.Loading -> {
+ for (i in 1..3) {
+ item("skeleton.$i") {
+ SkeletonItem()
+ }
+ }
+ }
+
+ is Loadable.Ok -> {
+ contentState.value.fold(
+ ifLeft = { e ->
+ item("error") {
+ ErrorView(
+ text = {
+ Text(text = "Failed to load filters list!")
+ },
+ exception = e,
+ )
+ }
+ },
+ ifRight = { content ->
+ val items = content.items
+ if (items.isEmpty()) {
+ item("empty") {
+ NoItemsPlaceholder()
+ }
+ }
+
+ items(
+ items = items,
+ key = { it.key },
+ ) { item ->
+ FilterItem(
+ modifier = Modifier
+ .animateItemPlacement(),
+ item = item,
+ )
+ }
+ },
+ )
+ }
+ }
+
+ if (CurrentPlatform.hasDynamicShortcuts()) item("note") {
+ Spacer(
+ modifier = Modifier
+ .height(32.dp),
+ )
+ Icon(
+ modifier = Modifier
+ .padding(horizontal = Dimens.horizontalPadding),
+ imageVector = Icons.Outlined.Info,
+ contentDescription = null,
+ tint = LocalContentColor.current.combineAlpha(alpha = MediumEmphasisAlpha),
+ )
+ Spacer(
+ modifier = Modifier
+ .height(16.dp),
+ )
+ Text(
+ modifier = Modifier
+ .padding(horizontal = Dimens.horizontalPadding),
+ text = stringResource(Res.strings.customfilters_dynamic_shortcut_tip),
+ style = MaterialTheme.typography.bodyMedium,
+ color = LocalContentColor.current
+ .combineAlpha(alpha = MediumEmphasisAlpha),
+ )
+ }
+ }
+}
+
+@Composable
+private fun NoItemsPlaceholder(
+ modifier: Modifier = Modifier,
+) {
+ EmptySearchView(
+ modifier = modifier,
+ )
+}
+
+@Composable
+private fun FilterItem(
+ modifier: Modifier,
+ item: CipherFiltersListState.Item,
+) {
+ val selectableState by item.selectableState.collectAsState()
+
+ val onClick = selectableState.onClick
+ // fallback to default
+ ?: item.onClick
+ .takeIf { selectableState.can }
+ val onLongClick = selectableState.onLongClick
+
+ val backgroundColor = when {
+ selectableState.selected -> MaterialTheme.colorScheme.primaryContainer
+ else -> run {
+ if (LocalHasDetailPane.current) {
+ val nextEntry = navigationNextEntryOrNull()
+ val nextRoute = nextEntry?.route as? CipherFilterViewFullRoute
+
+ val selected = nextRoute?.args?.model?.id == item.key
+ if (selected) {
+ return@run MaterialTheme.colorScheme.selectedContainer
+ }
+ }
+
+ Color.Unspecified
+ }
+ }
+ FlatItem(
+ modifier = modifier,
+ backgroundColor = backgroundColor,
+ leading = {
+ val accent = rememberSecretAccentColor(
+ accentLight = item.accentLight,
+ accentDark = item.accentDark,
+ )
+ AvatarBuilder(
+ icon = item.icon,
+ accent = accent,
+ active = true,
+ badge = {
+ // Do nothing.
+ },
+ )
+ },
+ title = {
+ Text(item.name)
+ },
+ trailing = {
+ Spacer(
+ modifier = Modifier
+ .width(8.dp),
+ )
+
+ val checkbox = when {
+ selectableState.selecting -> true
+ else -> false
+ }
+ Crossfade(
+ modifier = Modifier
+ .size(
+ width = 36.dp,
+ height = 36.dp,
+ ),
+ targetState = checkbox,
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (it) {
+ Checkbox(
+ checked = selectableState.selected,
+ onCheckedChange = null,
+ )
+ } else {
+ ChevronIcon()
+ }
+ }
+ }
+ },
+ onClick = onClick,
+ onLongClick = onLongClick,
+ )
+}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/list/CipherFiltersListState.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/list/CipherFiltersListState.kt
new file mode 100644
index 00000000..59e3d2c4
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/list/CipherFiltersListState.kt
@@ -0,0 +1,47 @@
+package com.artemchep.keyguard.feature.filter.list
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.AnnotatedString
+import arrow.core.Either
+import com.artemchep.keyguard.common.model.DCipherFilter
+import com.artemchep.keyguard.common.model.Loadable
+import com.artemchep.keyguard.feature.attachments.SelectableItemState
+import com.artemchep.keyguard.feature.auth.common.TextFieldModel2
+import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
+import com.artemchep.keyguard.ui.Selection
+import kotlinx.coroutines.flow.StateFlow
+
+data class CipherFiltersListState(
+ val filter: StateFlow,
+ val selection: StateFlow,
+ val content: Loadable>,
+) {
+ @Immutable
+ data class Filter(
+ val revision: Int,
+ val query: TextFieldModel2,
+ ) {
+ companion object
+ }
+
+ @Immutable
+ data class Content(
+ val revision: Int,
+ val items: List- ,
+ ) {
+ companion object
+ }
+
+ @Immutable
+ data class Item(
+ val key: String,
+ val icon: VaultItemIcon,
+ val accentLight: Color,
+ val accentDark: Color,
+ val name: AnnotatedString,
+ val data: DCipherFilter,
+ val selectableState: StateFlow,
+ val onClick: () -> Unit,
+ )
+}
\ No newline at end of file
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/list/CipherFiltersListStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/list/CipherFiltersListStateProducer.kt
new file mode 100644
index 00000000..e3d4a2ff
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/list/CipherFiltersListStateProducer.kt
@@ -0,0 +1,281 @@
+package com.artemchep.keyguard.feature.filter.list
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.Edit
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.text.AnnotatedString
+import arrow.core.partially1
+import com.artemchep.keyguard.common.model.DCipherFilter
+import com.artemchep.keyguard.common.model.Loadable
+import com.artemchep.keyguard.common.service.filter.GetCipherFilters
+import com.artemchep.keyguard.common.service.filter.RemoveCipherFilterById
+import com.artemchep.keyguard.common.service.filter.RenameCipherFilter
+import com.artemchep.keyguard.common.util.flow.persistingStateIn
+import com.artemchep.keyguard.feature.attachments.SelectableItemState
+import com.artemchep.keyguard.feature.attachments.SelectableItemStateRaw
+import com.artemchep.keyguard.feature.crashlytics.crashlyticsAttempt
+import com.artemchep.keyguard.feature.filter.CipherFiltersRoute
+import com.artemchep.keyguard.feature.filter.util.CipherFilterUtil
+import com.artemchep.keyguard.feature.filter.view.CipherFilterViewDialogRoute
+import com.artemchep.keyguard.feature.filter.view.CipherFilterViewFullRoute
+import com.artemchep.keyguard.feature.generator.wordlist.util.WordlistUtil
+import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
+import com.artemchep.keyguard.feature.home.vault.model.short
+import com.artemchep.keyguard.feature.home.vault.search.IndexedText
+import com.artemchep.keyguard.feature.navigation.NavigationIntent
+import com.artemchep.keyguard.feature.navigation.state.produceScreenState
+import com.artemchep.keyguard.feature.search.search.IndexedModel
+import com.artemchep.keyguard.feature.search.search.mapSearch
+import com.artemchep.keyguard.feature.search.search.searchFilter
+import com.artemchep.keyguard.feature.search.search.searchQueryHandle
+import com.artemchep.keyguard.res.Res
+import com.artemchep.keyguard.ui.FlatItemAction
+import com.artemchep.keyguard.ui.Selection
+import com.artemchep.keyguard.ui.buildContextItems
+import com.artemchep.keyguard.ui.icons.icon
+import com.artemchep.keyguard.ui.selection.selectionHandle
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
+import org.kodein.di.compose.localDI
+import org.kodein.di.direct
+import org.kodein.di.instance
+
+private class CipherFiltersListUiException(
+ msg: String,
+ cause: Throwable,
+) : RuntimeException(msg, cause)
+
+@Composable
+fun produceCipherFiltersListState(
+) = with(localDI().direct) {
+ produceCipherFiltersListState(
+ getCipherFilters = instance(),
+ removeCipherFilterById = instance(),
+ renameCipherFilter = instance(),
+ )
+}
+
+@Composable
+fun produceCipherFiltersListState(
+ getCipherFilters: GetCipherFilters,
+ removeCipherFilterById: RemoveCipherFilterById,
+ renameCipherFilter: RenameCipherFilter,
+): Loadable = produceScreenState(
+ key = "cipher_filter_list",
+ initial = Loadable.Loading,
+ args = arrayOf(),
+) {
+ val selectionHandle = selectionHandle("selection")
+ val queryHandle = searchQueryHandle("query")
+ val queryFlow = searchFilter(queryHandle) { model, revision ->
+ CipherFiltersListState.Filter(
+ revision = revision,
+ query = model,
+ )
+ }
+
+ fun onClick(model: DCipherFilter) {
+ val route = CipherFilterViewFullRoute(
+ args = CipherFilterViewDialogRoute.Args(
+ model = model,
+ ),
+ )
+ val intent = NavigationIntent.Composite(
+ listOf(
+ NavigationIntent.PopById(CipherFiltersRoute.ROUTER_NAME),
+ NavigationIntent.NavigateToRoute(route),
+ ),
+ )
+ navigate(intent)
+ }
+
+ suspend fun List.toItems(): List {
+ return this
+ .map { filter ->
+ val id = filter.id
+ val icon = if (filter.icon != null) {
+ VaultItemIcon.VectorIcon(
+ imageVector = filter.icon,
+ )
+ } else {
+ VaultItemIcon.TextIcon.short(filter.name)
+ }
+
+ val selectableFlow = selectionHandle
+ .idsFlow
+ .map { selectedIds ->
+ SelectableItemStateRaw(
+ selecting = selectedIds.isNotEmpty(),
+ selected = id in selectedIds,
+ )
+ }
+ .distinctUntilChanged()
+ .map { raw ->
+ val onClick = if (raw.selecting) {
+ // lambda
+ selectionHandle::toggleSelection.partially1(id)
+ } else {
+ null
+ }
+ val onLongClick = if (raw.selecting) {
+ null
+ } else {
+ // lambda
+ selectionHandle::toggleSelection.partially1(id)
+ }
+ SelectableItemState(
+ selecting = raw.selecting,
+ selected = raw.selected,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ )
+ }
+ val selectableStateFlow =
+ if (this.size >= 100) {
+ val sharing = SharingStarted.WhileSubscribed(1000L)
+ selectableFlow.persistingStateIn(this@produceScreenState, sharing)
+ } else {
+ selectableFlow.stateIn(this@produceScreenState)
+ }
+ CipherFiltersListState.Item(
+ key = id,
+ icon = icon,
+ accentLight = filter.accentColor.light,
+ accentDark = filter.accentColor.dark,
+ name = AnnotatedString(filter.name),
+ data = filter,
+ selectableState = selectableStateFlow,
+ onClick = ::onClick
+ .partially1(filter),
+ )
+ }
+ }
+
+ val itemsRawFlow = getCipherFilters()
+ val itemsFlow = itemsRawFlow
+ .map { filters ->
+ filters
+ .toItems()
+ // Index for the search.
+ .map { item ->
+ IndexedModel(
+ model = item,
+ indexedText = IndexedText.invoke(item.name.text),
+ )
+ }
+ }
+ .mapSearch(
+ handle = queryHandle,
+ ) { item, result ->
+ // Replace the origin text with the one with
+ // search decor applied to it.
+ item.copy(name = result.highlightedText)
+ }
+ // Automatically de-select items
+ // that do not exist.
+ combine(
+ itemsRawFlow,
+ selectionHandle.idsFlow,
+ ) { items, selectedItemIds ->
+ val newSelectedItemIds = selectedItemIds
+ .asSequence()
+ .filter { id ->
+ items.any { it.id == id }
+ }
+ .toSet()
+ newSelectedItemIds.takeIf { it.size < selectedItemIds.size }
+ }
+ .filterNotNull()
+ .onEach { ids -> selectionHandle.setSelection(ids) }
+ .launchIn(screenScope)
+ val selectionFlow = combine(
+ itemsRawFlow,
+ selectionHandle.idsFlow,
+ ) { items, selectedItemIds ->
+ val selectedItems = items
+ .filter { it.id in selectedItemIds }
+ items to selectedItems
+ }
+ .map { (allItems, selectedItems) ->
+ if (selectedItems.isEmpty()) {
+ return@map null
+ }
+
+ val actions = buildContextItems {
+ if (selectedItems.size == 1) {
+ section {
+ val selectedItem = selectedItems.first()
+ this += FlatItemAction(
+ icon = Icons.Outlined.Edit,
+ title = translate(Res.strings.edit),
+ onClick = CipherFilterUtil::onRename
+ .partially1(this@produceScreenState)
+ .partially1(renameCipherFilter)
+ .partially1(selectedItem),
+ )
+ }
+ }
+ section {
+ this += FlatItemAction(
+ leading = icon(Icons.Outlined.Delete),
+ title = translate(Res.strings.delete),
+ onClick = CipherFilterUtil::onDeleteByItems
+ .partially1(this@produceScreenState)
+ .partially1(removeCipherFilterById)
+ .partially1(selectedItems),
+ )
+ }
+ }
+ Selection(
+ count = selectedItems.size,
+ actions = actions.toPersistentList(),
+ onSelectAll = if (selectedItems.size < allItems.size) {
+ val allIds = allItems
+ .asSequence()
+ .map { it.id }
+ .toSet()
+ selectionHandle::setSelection
+ .partially1(allIds)
+ } else {
+ null
+ },
+ onClear = selectionHandle::clearSelection,
+ )
+ }
+ .stateIn(screenScope)
+ val contentFlow = itemsFlow
+ .crashlyticsAttempt { e ->
+ val msg = "Failed to get the cipher filters list!"
+ CipherFiltersListUiException(
+ msg = msg,
+ cause = e,
+ )
+ }
+ .map { result ->
+ val contentOrException = result
+ .map { (items, revision) ->
+ CipherFiltersListState.Content(
+ revision = revision,
+ items = items,
+ )
+ }
+ Loadable.Ok(contentOrException)
+ }
+ contentFlow
+ .map { content ->
+ val state = CipherFiltersListState(
+ filter = queryFlow,
+ selection = selectionFlow,
+ content = content,
+ )
+ Loadable.Ok(state)
+ }
+}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/util/CipherFilterUtil.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/util/CipherFilterUtil.kt
new file mode 100644
index 00000000..52159112
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/util/CipherFilterUtil.kt
@@ -0,0 +1,94 @@
+package com.artemchep.keyguard.feature.filter.util
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.Edit
+import com.artemchep.keyguard.common.io.launchIn
+import com.artemchep.keyguard.common.model.DCipherFilter
+import com.artemchep.keyguard.common.service.filter.RemoveCipherFilterById
+import com.artemchep.keyguard.common.service.filter.RenameCipherFilter
+import com.artemchep.keyguard.common.service.filter.model.RenameCipherFilterRequest
+import com.artemchep.keyguard.feature.confirmation.ConfirmationResult
+import com.artemchep.keyguard.feature.confirmation.ConfirmationRoute
+import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogIntent
+import com.artemchep.keyguard.feature.navigation.NavigationIntent
+import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver
+import com.artemchep.keyguard.feature.navigation.state.RememberStateFlowScope
+import com.artemchep.keyguard.res.Res
+import com.artemchep.keyguard.ui.icons.KeyguardCipherFilter
+import com.artemchep.keyguard.ui.icons.KeyguardWordlist
+import com.artemchep.keyguard.ui.icons.icon
+
+object CipherFilterUtil {
+ context(RememberStateFlowScope)
+ fun onRename(
+ renameCipherFilter: RenameCipherFilter,
+ model: DCipherFilter,
+ ) {
+ val nameKey = "name"
+ val nameItem = ConfirmationRoute.Args.Item.StringItem(
+ key = nameKey,
+ value = model.name,
+ title = translate(Res.strings.generic_name),
+ type = ConfirmationRoute.Args.Item.StringItem.Type.Text,
+ canBeEmpty = false,
+ )
+
+ val items = listOfNotNull(
+ nameItem,
+ )
+ val route = registerRouteResultReceiver(
+ route = ConfirmationRoute(
+ args = ConfirmationRoute.Args(
+ icon = icon(
+ main = Icons.Outlined.KeyguardCipherFilter,
+ secondary = Icons.Outlined.Edit,
+ ),
+ title = translate(Res.strings.customfilters_edit_filter_title),
+ items = items,
+ docUrl = null,
+ ),
+ ),
+ ) { result ->
+ if (result is ConfirmationResult.Confirm) {
+ val name = result.data[nameKey] as? String
+ ?: return@registerRouteResultReceiver
+
+ val request = RenameCipherFilterRequest(
+ id = model.idRaw,
+ name = name,
+ )
+ renameCipherFilter(request)
+ .launchIn(appScope)
+ }
+ }
+ val intent = NavigationIntent.NavigateToRoute(route)
+ navigate(intent)
+ }
+
+ context(RememberStateFlowScope)
+ fun onDeleteByItems(
+ removeCipherFilterById: RemoveCipherFilterById,
+ items: List,
+ ) {
+ val title = if (items.size > 1) {
+ translate(Res.strings.customfilters_delete_many_confirmation_title)
+ } else {
+ translate(Res.strings.customfilters_delete_one_confirmation_title)
+ }
+ val message = items
+ .joinToString(separator = "\n") { it.name }
+ val intent = createConfirmationDialogIntent(
+ icon = icon(Icons.Outlined.Delete),
+ title = title,
+ message = message,
+ ) {
+ val ids = items
+ .map { it.idRaw }
+ .toSet()
+ removeCipherFilterById(ids)
+ .launchIn(appScope)
+ }
+ navigate(intent)
+ }
+}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/view/CipherFilterViewDialogRoute.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/view/CipherFilterViewDialogRoute.kt
new file mode 100644
index 00000000..4fbb3c03
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/view/CipherFilterViewDialogRoute.kt
@@ -0,0 +1,18 @@
+package com.artemchep.keyguard.feature.filter.view
+
+import androidx.compose.runtime.Composable
+import com.artemchep.keyguard.common.model.DCipherFilter
+import com.artemchep.keyguard.common.service.passkey.PassKeyServiceInfo
+import com.artemchep.keyguard.feature.navigation.DialogRoute
+
+data class CipherFilterViewDialogRoute(
+ val args: Args,
+) : DialogRoute {
+ data class Args(
+ val model: DCipherFilter,
+ )
+
+ @Composable
+ override fun Content() {
+ }
+}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/view/CipherFilterViewFullRoute.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/view/CipherFilterViewFullRoute.kt
new file mode 100644
index 00000000..123073cf
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/view/CipherFilterViewFullRoute.kt
@@ -0,0 +1,15 @@
+package com.artemchep.keyguard.feature.filter.view
+
+import androidx.compose.runtime.Composable
+import com.artemchep.keyguard.feature.navigation.Route
+
+data class CipherFilterViewFullRoute(
+ val args: CipherFilterViewDialogRoute.Args,
+) : Route {
+ @Composable
+ override fun Content() {
+ CipherFilterViewFullScreen(
+ args = args,
+ )
+ }
+}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/view/CipherFilterViewScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/view/CipherFilterViewScreen.kt
new file mode 100644
index 00000000..4c8f69ba
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/view/CipherFilterViewScreen.kt
@@ -0,0 +1,121 @@
+package com.artemchep.keyguard.feature.filter.view
+
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import com.artemchep.keyguard.common.model.Loadable
+import com.artemchep.keyguard.feature.navigation.NavigationIcon
+import com.artemchep.keyguard.feature.search.filter.FilterItems
+import com.artemchep.keyguard.feature.search.filter.FilterScreen
+import com.artemchep.keyguard.ui.OptionsButton
+import com.artemchep.keyguard.ui.ScaffoldColumn
+import com.artemchep.keyguard.ui.toolbar.LargeToolbar
+
+@Composable
+fun CipherFilterViewFullScreen(
+ args: CipherFilterViewDialogRoute.Args,
+) {
+ CipherFilterViewScreen(
+ args = args,
+ )
+}
+
+@Composable
+fun CipherFilterViewScreen(
+ args: CipherFilterViewDialogRoute.Args,
+) {
+ val loadableState = produceCipherFilterViewState(
+ args = args,
+ )
+
+ val title = args.model.name
+ val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+ when (loadableState) {
+ is Loadable.Ok -> {
+ val state = loadableState.value
+ CipherFilterViewScreenOk(
+ title = title,
+ scrollBehavior = scrollBehavior,
+ state = state,
+ )
+ }
+
+ is Loadable.Loading -> {
+ CipherFilterViewSkeleton(
+ title = title,
+ scrollBehavior = scrollBehavior,
+ )
+ }
+ }
+}
+
+@Composable
+fun CipherFilterViewSkeleton(
+ title: String,
+ scrollBehavior: TopAppBarScrollBehavior,
+) {
+ ScaffoldColumn(
+ modifier = Modifier
+ .nestedScroll(scrollBehavior.nestedScrollConnection),
+ topAppBarScrollBehavior = scrollBehavior,
+ topBar = {
+ LargeToolbar(
+ title = {
+ Text(
+ text = title,
+ )
+ },
+ navigationIcon = {
+ NavigationIcon()
+ },
+ scrollBehavior = scrollBehavior,
+ )
+ },
+ ) {
+ }
+}
+
+@Composable
+fun CipherFilterViewScreenOk(
+ title: String,
+ scrollBehavior: TopAppBarScrollBehavior,
+ state: CipherFilterViewState,
+) {
+ val content by state.toolbarFlow.collectAsState()
+ ScaffoldColumn(
+ modifier = Modifier
+ .nestedScroll(scrollBehavior.nestedScrollConnection),
+ topAppBarScrollBehavior = scrollBehavior,
+ topBar = {
+ LargeToolbar(
+ title = {
+ Text(
+ text = content.model?.name
+ ?: title,
+ )
+ },
+ navigationIcon = {
+ NavigationIcon()
+ },
+ actions = {
+ OptionsButton(
+ actions = content.actions,
+ )
+ },
+ scrollBehavior = scrollBehavior,
+ )
+ },
+ ) {
+ val filter by state.filterFlow.collectAsState(
+ initial = CipherFilterViewState.Filter(),
+ )
+ FilterItems(
+ items = filter.items,
+ )
+ }
+}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/view/CipherFilterViewState.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/view/CipherFilterViewState.kt
new file mode 100644
index 00000000..dc63128b
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/view/CipherFilterViewState.kt
@@ -0,0 +1,36 @@
+package com.artemchep.keyguard.feature.filter.view
+
+import androidx.compose.runtime.Immutable
+import arrow.core.Either
+import com.artemchep.keyguard.common.model.DCipherFilter
+import com.artemchep.keyguard.feature.home.vault.model.FilterItem
+import com.artemchep.keyguard.ui.ContextItem
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.StateFlow
+
+data class CipherFilterViewState(
+ val toolbarFlow: StateFlow,
+ val filterFlow: Flow,
+ val content: Either,
+ val onClose: (() -> Unit)? = null,
+) {
+ @Immutable
+ data class Content(
+ val model: DCipherFilter?,
+ ) {
+ companion object
+ }
+
+ @Immutable
+ data class Toolbar(
+ val model: DCipherFilter?,
+ val actions: ImmutableList,
+ )
+
+ @Immutable
+ data class Filter(
+ val items: ImmutableList = persistentListOf(),
+ )
+}
\ No newline at end of file
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/view/CipherFilterViewStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/view/CipherFilterViewStateProducer.kt
new file mode 100644
index 00000000..3e6dd290
--- /dev/null
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/filter/view/CipherFilterViewStateProducer.kt
@@ -0,0 +1,315 @@
+package com.artemchep.keyguard.feature.filter.view
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.Edit
+import androidx.compose.material.icons.outlined.FolderOff
+import androidx.compose.runtime.Composable
+import arrow.core.partially1
+import arrow.core.right
+import com.artemchep.keyguard.common.model.DFilter
+import com.artemchep.keyguard.common.model.Loadable
+import com.artemchep.keyguard.common.service.filter.GetCipherFilters
+import com.artemchep.keyguard.common.service.filter.RemoveCipherFilterById
+import com.artemchep.keyguard.common.service.filter.RenameCipherFilter
+import com.artemchep.keyguard.common.usecase.GetAccounts
+import com.artemchep.keyguard.common.usecase.GetCiphers
+import com.artemchep.keyguard.common.usecase.GetCollections
+import com.artemchep.keyguard.common.usecase.GetFolders
+import com.artemchep.keyguard.common.usecase.GetOrganizations
+import com.artemchep.keyguard.common.usecase.GetProfiles
+import com.artemchep.keyguard.common.util.flow.foldAsList
+import com.artemchep.keyguard.feature.filter.util.CipherFilterUtil
+import com.artemchep.keyguard.feature.home.vault.model.FilterItem
+import com.artemchep.keyguard.feature.home.vault.screen.FilterSection
+import com.artemchep.keyguard.feature.navigation.state.navigatePopSelf
+import com.artemchep.keyguard.feature.navigation.state.produceScreenState
+import com.artemchep.keyguard.feature.navigation.state.translate
+import com.artemchep.keyguard.res.Res
+import com.artemchep.keyguard.ui.FlatItemAction
+import com.artemchep.keyguard.ui.autoclose.launchAutoPopSelfHandler
+import com.artemchep.keyguard.ui.buildContextItems
+import com.artemchep.keyguard.ui.icons.iconSmall
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import org.kodein.di.compose.localDI
+import org.kodein.di.direct
+import org.kodein.di.instance
+
+@Composable
+fun produceCipherFilterViewState(
+ args: CipherFilterViewDialogRoute.Args,
+) = with(localDI().direct) {
+ produceCipherFilterViewState(
+ args = args,
+ getCipherFilters = instance(),
+ removeCipherFilterById = instance(),
+ renameCipherFilter = instance(),
+ getAccounts = instance(),
+ getProfiles = instance(),
+ getOrganizations = instance(),
+ getCollections = instance(),
+ getFolders = instance(),
+ getCiphers = instance(),
+ )
+}
+
+@Composable
+fun produceCipherFilterViewState(
+ args: CipherFilterViewDialogRoute.Args,
+ getCipherFilters: GetCipherFilters,
+ removeCipherFilterById: RemoveCipherFilterById,
+ renameCipherFilter: RenameCipherFilter,
+ getAccounts: GetAccounts,
+ getProfiles: GetProfiles,
+ getOrganizations: GetOrganizations,
+ getCollections: GetCollections,
+ getFolders: GetFolders,
+ getCiphers: GetCiphers,
+): Loadable = produceScreenState(
+ key = "cipher_filter_view",
+ initial = Loadable.Loading,
+ args = arrayOf(),
+) {
+ val filterFlow = getCipherFilters()
+ .map { filters ->
+ filters
+ .firstOrNull { it.idRaw == args.model.idRaw }
+ }
+ .shareInScreenScope()
+ launchAutoPopSelfHandler(filterFlow)
+
+ val profilesMapFlow = getProfiles()
+ .map { profiles ->
+ profiles.associateBy { it.accountId }
+ }
+ val organizationsMapFlow = getOrganizations()
+ .map { organizations ->
+ organizations
+ .associate {
+ it.id to it.name
+ }
+ }
+ val collectionsMapFlow = getCollections()
+ .map { collections ->
+ collections
+ .associate {
+ it.id to it.name
+ }
+ }
+ val foldersMapFlow = getFolders()
+ .map { folders ->
+ folders
+ .associate {
+ it.id to it.name
+ }
+ }
+ val ciphersMapFlow = getCiphers()
+ .map { ciphers ->
+ ciphers
+ .associate {
+ it.id to it.name
+ }
+ }
+
+ val listFlow = filterFlow
+ .map { filter ->
+ filter
+ ?: return@map emptyList>()
+
+ val l = mutableListOf>()
+ FilterSection.entries.forEach { filterSection ->
+ val filterSet = filter.filter[filterSection.id]
+ ?: return@forEach
+ // Should never happen, but the structure
+ // definitely allows it.
+ if (filterSet.isEmpty()) {
+ return@forEach
+ }
+
+ fun createFilterItem(
+ filter: DFilter.Primitive,
+ leading: (@Composable () -> Unit)?,
+ title: String,
+ text: String? = null,
+ ): FilterItem.Item {
+ return FilterItem.Item(
+ sectionId = filterSection.id,
+ filterSectionId = filterSection.id,
+ filter = FilterItem.Item.Filter.Toggle(
+ filters = setOf(filter),
+ ),
+ checked = false,
+ enabled = true,
+ fill = false,
+ leading = leading,
+ title = title,
+ text = text,
+ onClick = null,
+ )
+ }
+
+ l += FilterItem.Section(
+ sectionId = filterSection.id,
+ text = translate(filterSection.title),
+ expandable = false,
+ onClick = null,
+ ).let(::flowOf)
+ filterSet.forEach { f ->
+ when (f) {
+ is DFilter.PrimitiveSimple -> {
+ l += createFilterItem(
+ filter = f,
+ leading = f.content.icon
+ ?.let {
+ iconSmall(it)
+ },
+ title = translate(f.content.title),
+ ).let(::flowOf)
+ }
+
+ is DFilter.ById -> {
+ val sourceFlow = when (f.what) {
+ DFilter.ById.What.ACCOUNT -> {
+ l += profilesMapFlow
+ .map { state -> state[f.id] }
+ .distinctUntilChanged()
+ .map { profile ->
+ createFilterItem(
+ filter = f,
+ leading = null,
+ title = profile?.email.orEmpty(),
+ text = profile?.accountHost.orEmpty(),
+ )
+ }
+ return@forEach
+ }
+
+ DFilter.ById.What.FOLDER -> {
+ if (f.id == null) {
+ l += createFilterItem(
+ filter = f,
+ leading = iconSmall(Icons.Outlined.FolderOff),
+ title = translate(Res.strings.folder_none),
+ ).let(::flowOf)
+ return@forEach
+ }
+
+ foldersMapFlow
+ }
+
+ DFilter.ById.What.COLLECTION -> {
+ if (f.id == null) {
+ l += createFilterItem(
+ filter = f,
+ leading = null,
+ title = translate(Res.strings.collection_none),
+ ).let(::flowOf)
+ return@forEach
+ }
+
+ collectionsMapFlow
+ }
+
+ DFilter.ById.What.ORGANIZATION -> {
+ if (f.id == null) {
+ l += createFilterItem(
+ filter = f,
+ leading = null,
+ title = translate(Res.strings.organization_none),
+ ).let(::flowOf)
+ return@forEach
+ }
+
+ organizationsMapFlow
+ }
+
+ DFilter.ById.What.CIPHER -> ciphersMapFlow
+ }
+
+ l += sourceFlow
+ .map { state -> state[f.id] }
+ .distinctUntilChanged()
+ .map { name ->
+ createFilterItem(
+ filter = f,
+ leading = null,
+ title = name.orEmpty(),
+ )
+ }
+ }
+ }
+ }
+ }
+ l
+ }
+ .flatMapLatest {
+ it.foldAsList()
+ }
+ .map {
+ CipherFilterViewState.Filter(
+ items = it.toPersistentList(),
+ )
+ }
+ val toolbarFlow = filterFlow
+ .map { filter ->
+ val actions = run {
+ filter
+ ?: return@run persistentListOf()
+ buildContextItems {
+ section {
+ this += FlatItemAction(
+ icon = Icons.Outlined.Edit,
+ title = translate(Res.strings.edit),
+ onClick = CipherFilterUtil::onRename
+ .partially1(this@produceScreenState)
+ .partially1(renameCipherFilter)
+ .partially1(filter),
+ )
+ }
+ section {
+ val filterAsItems = listOf(
+ filter,
+ )
+ this += FlatItemAction(
+ icon = Icons.Outlined.Delete,
+ title = translate(Res.strings.delete),
+ onClick = CipherFilterUtil::onDeleteByItems
+ .partially1(this@produceScreenState)
+ .partially1(removeCipherFilterById)
+ .partially1(filterAsItems),
+ )
+ }
+ }
+ }
+ CipherFilterViewState.Toolbar(
+ model = filter,
+ actions = actions,
+ )
+ }
+ .stateIn(screenScope)
+
+ filterFlow
+ .map { model ->
+ val content = CipherFilterViewState.Content(
+ model = model,
+ )
+ val state = CipherFilterViewState(
+ toolbarFlow = toolbarFlow,
+ filterFlow = listFlow,
+ content = content
+ .right(),
+ onClose = {
+ navigatePopSelf()
+ },
+ )
+ Loadable.Ok(state)
+ }
+}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/emailrelay/EmailRelayListStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/emailrelay/EmailRelayListStateProducer.kt
index 7860a298..27e521b7 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/emailrelay/EmailRelayListStateProducer.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/emailrelay/EmailRelayListStateProducer.kt
@@ -24,6 +24,7 @@ import com.artemchep.keyguard.feature.confirmation.ConfirmationRoute
import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogIntent
import com.artemchep.keyguard.feature.crashlytics.crashlyticsAttempt
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
+import com.artemchep.keyguard.feature.home.vault.model.short
import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver
import com.artemchep.keyguard.feature.navigation.state.produceScreenState
@@ -292,18 +293,7 @@ fun produceEmailRelayListState(
)
}
}
- val icon = VaultItemIcon.TextIcon(
- run {
- val words = it.name.split(" ")
- if (words.size <= 1) {
- return@run words.firstOrNull()?.take(2).orEmpty()
- }
-
- words
- .take(2)
- .joinToString("") { it.take(1) }
- }.uppercase(),
- )
+ val icon = VaultItemIcon.TextIcon.short(it.name)
val selectableFlow = selectionHandle
.idsFlow
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListScreen.kt
index 964d970e..978bba29 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListScreen.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListScreen.kt
@@ -293,9 +293,6 @@ private fun WordlistItem(
val nextEntry = navigationNextEntryOrNull()
val nextRoute = nextEntry?.route as? WordlistViewRoute
- MaterialTheme.colorScheme.selectedContainer
- .takeIf { LocalHasDetailPane.current }
- ?: Color.Unspecified
val selected = nextRoute?.args?.wordlistId == item.wordlistId
if (selected) {
return@run MaterialTheme.colorScheme.selectedContainer
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListStateProducer.kt
index d012db40..5a966dd5 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListStateProducer.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/list/WordlistListStateProducer.kt
@@ -1,7 +1,6 @@
package com.artemchep.keyguard.feature.generator.wordlist.list
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.AddLink
import androidx.compose.material.icons.outlined.AttachFile
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
@@ -22,6 +21,7 @@ import com.artemchep.keyguard.feature.generator.wordlist.WordlistsRoute
import com.artemchep.keyguard.feature.generator.wordlist.util.WordlistUtil
import com.artemchep.keyguard.feature.generator.wordlist.view.WordlistViewRoute
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
+import com.artemchep.keyguard.feature.home.vault.model.short
import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.state.produceScreenState
import com.artemchep.keyguard.res.Res
@@ -128,7 +128,7 @@ fun produceWordlistListState(
this += FlatItemAction(
icon = Icons.Outlined.Edit,
title = translate(Res.strings.edit),
- onClick = WordlistUtil::onEdit
+ onClick = WordlistUtil::onRename
.partially1(this@produceScreenState)
.partially1(editWordlist)
.partially1(selectedItem),
@@ -166,18 +166,7 @@ fun produceWordlistListState(
.map { list ->
list
.map {
- val icon = VaultItemIcon.TextIcon(
- run {
- val words = it.name.split(" ")
- if (words.size <= 1) {
- return@run words.firstOrNull()?.take(2).orEmpty()
- }
-
- words
- .take(2)
- .joinToString("") { it.take(1) }
- }.uppercase(),
- )
+ val icon = VaultItemIcon.TextIcon.short(it.name)
val selectableFlow = selectionHandle
.idsFlow
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/util/WordlistUtil.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/util/WordlistUtil.kt
index fdb7dd87..c1a04566 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/util/WordlistUtil.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/util/WordlistUtil.kt
@@ -18,12 +18,13 @@ import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver
import com.artemchep.keyguard.feature.navigation.state.RememberStateFlowScope
import com.artemchep.keyguard.res.Res
+import com.artemchep.keyguard.ui.icons.KeyguardCipherFilter
import com.artemchep.keyguard.ui.icons.KeyguardWordlist
import com.artemchep.keyguard.ui.icons.icon
object WordlistUtil {
context(RememberStateFlowScope)
- fun onEdit(
+ fun onRename(
editWordlist: EditWordlist,
entity: DGeneratorWordlist,
) {
@@ -43,7 +44,7 @@ object WordlistUtil {
route = ConfirmationRoute(
args = ConfirmationRoute.Args(
icon = icon(
- main = Icons.Outlined.KeyguardWordlist,
+ main = Icons.Outlined.KeyguardCipherFilter,
secondary = Icons.Outlined.Edit,
),
title = translate(Res.strings.wordlist_edit_wordlist_title),
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/view/WordlistViewStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/view/WordlistViewStateProducer.kt
index 5fd28957..21de5a53 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/view/WordlistViewStateProducer.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/generator/wordlist/view/WordlistViewStateProducer.kt
@@ -79,7 +79,7 @@ fun produceWordlistViewState(
this += FlatItemAction(
icon = Icons.Outlined.Edit,
title = translate(Res.strings.edit),
- onClick = WordlistUtil::onEdit
+ onClick = WordlistUtil::onRename
.partially1(this@produceScreenState)
.partially1(editWordlist)
.partially1(wordlist),
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/accounts/AccountListViewModel.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/accounts/AccountListViewModel.kt
index 54638928..de449bb4 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/accounts/AccountListViewModel.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/settings/accounts/AccountListViewModel.kt
@@ -26,6 +26,7 @@ import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogInten
import com.artemchep.keyguard.feature.home.settings.accounts.model.AccountItem
import com.artemchep.keyguard.feature.home.vault.VaultRoute
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
+import com.artemchep.keyguard.feature.home.vault.model.short
import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.state.produceScreenState
import com.artemchep.keyguard.res.Res
@@ -235,18 +236,7 @@ fun accountListScreenState(
val busy = syncing || removing
val accent = profile?.accentColor
?: generateAccentColorsByAccountId(it.id.id)
- val icon = VaultItemIcon.TextIcon(
- run {
- val words = profile?.name?.split(" ").orEmpty()
- if (words.size <= 1) {
- return@run words.firstOrNull()?.take(2).orEmpty()
- }
-
- words
- .take(2)
- .joinToString("") { it.take(1) }
- }.uppercase(),
- )
+ val icon = VaultItemIcon.TextIcon.short(profile?.name.orEmpty())
AccountItem.Item(
id = it.id.id,
icon = icon,
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultListItem.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultListItem.kt
index 7a116194..270aa10b 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultListItem.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/component/VaultListItem.kt
@@ -778,7 +778,8 @@ fun BoxScope.VaultItemIcon2(
.align(Alignment.Center),
imageVector = icon.imageVector,
contentDescription = null,
- tint = Color.Black,
+ tint = Color.Black
+ .combineAlpha(MediumEmphasisAlpha),
)
}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/model/FilterItem.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/model/FilterItem.kt
index a3494d62..24ff887a 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/model/FilterItem.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/model/FilterItem.kt
@@ -14,8 +14,9 @@ sealed interface FilterItem : FilterItemModel {
data class Section(
override val sectionId: String,
override val text: String,
+ override val expandable: Boolean = true,
override val expanded: Boolean = true,
- override val onClick: () -> Unit,
+ override val onClick: (() -> Unit)?,
) : FilterItem, FilterItemModel.Section {
companion object;
@@ -25,7 +26,12 @@ sealed interface FilterItem : FilterItemModel {
data class Item(
override val sectionId: String,
val filterSectionId: String,
- val filters: Set,
+ val filter: Filter,
+ /**
+ * Unique identifier of the set of
+ * filters.
+ */
+ val filterId: String = filter.id,
override val checked: Boolean,
override val fill: Boolean,
override val indent: Int = 0,
@@ -33,10 +39,25 @@ sealed interface FilterItem : FilterItemModel {
override val title: String,
override val text: String?,
override val onClick: (() -> Unit)?,
+ override val enabled: Boolean = onClick != null,
) : FilterItem, FilterItemModel.Item {
companion object;
- override val id: String =
- sectionId + "|" + filterSectionId + "|" + filters.joinToString(separator = ",") { it.key }
+ override val id: String = "$sectionId|$filterSectionId|$filterId"
+
+ sealed interface Filter {
+ val id: String
+
+ data class Toggle(
+ val filters: Set,
+ override val id: String = filters
+ .joinToString(separator = ",") { it.key },
+ ) : Filter
+
+ data class Apply(
+ val filters: Map>,
+ override val id: String,
+ ) : Filter
+ }
}
}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/model/VaultItemIcon.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/model/VaultItemIcon.kt
index d0edf288..9254fae0 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/model/VaultItemIcon.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/model/VaultItemIcon.kt
@@ -2,6 +2,8 @@ package com.artemchep.keyguard.feature.home.vault.model
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.vector.ImageVector
+import com.artemchep.keyguard.common.util.asCodePointsSequence
+import com.artemchep.keyguard.common.util.nextSymbol
import com.artemchep.keyguard.feature.favicon.AppIconUrl
import com.artemchep.keyguard.feature.favicon.FaviconUrl
import dev.icerock.moko.resources.ImageResource
@@ -33,5 +35,30 @@ sealed interface VaultItemIcon {
@Immutable
data class TextIcon(
val text: String,
- ) : VaultItemIcon
+ ) : VaultItemIcon {
+ companion object
+ }
+}
+
+fun VaultItemIcon.TextIcon.Companion.short(text: String): VaultItemIcon.TextIcon {
+ val abbr = kotlin.run {
+ val words = text.split(" ")
+ if (words.size <= 1) {
+ val word = words.firstOrNull()
+ ?: return@run ""
+ return@run (0 until 2)
+ .fold("") { str, _ ->
+ str + word.nextSymbol(index = str.length)
+ }
+ }
+
+ words
+ .take(2)
+ .joinToString("") {
+ it.nextSymbol()
+ }
+ }
+ return VaultItemIcon.TextIcon(
+ text = abbr,
+ )
}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListFilter.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListFilter.kt
index 2ed5b1d6..a6174694 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListFilter.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListFilter.kt
@@ -6,8 +6,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.CloudOff
import androidx.compose.material.icons.outlined.ErrorOutline
+import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.outlined.FolderOff
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.outlined.Lock
@@ -18,6 +20,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import arrow.core.partially1
import arrow.core.widen
+import com.artemchep.keyguard.common.io.launchIn
import com.artemchep.keyguard.common.model.DAccount
import com.artemchep.keyguard.common.model.DCollection
import com.artemchep.keyguard.common.model.DFilter
@@ -27,22 +30,37 @@ import com.artemchep.keyguard.common.model.DProfile
import com.artemchep.keyguard.common.model.DSecret
import com.artemchep.keyguard.common.model.iconImageVector
import com.artemchep.keyguard.common.model.titleH
+import com.artemchep.keyguard.common.service.filter.AddCipherFilter
+import com.artemchep.keyguard.common.service.filter.GetCipherFilters
+import com.artemchep.keyguard.common.service.filter.model.AddCipherFilterRequest
import com.artemchep.keyguard.common.usecase.GetFolderTree
import com.artemchep.keyguard.common.util.StringComparatorIgnoreCase
+import com.artemchep.keyguard.feature.confirmation.ConfirmationRoute
+import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogIntent
import com.artemchep.keyguard.feature.home.vault.component.rememberSecretAccentColor
import com.artemchep.keyguard.feature.home.vault.model.FilterItem
import com.artemchep.keyguard.feature.home.vault.search.filter.FilterHolder
+import com.artemchep.keyguard.feature.localization.TextHolder
import com.artemchep.keyguard.feature.navigation.state.PersistedStorage
import com.artemchep.keyguard.feature.navigation.state.RememberStateFlowScope
+import com.artemchep.keyguard.feature.navigation.state.translate
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.icons.AccentColors
import com.artemchep.keyguard.ui.icons.IconBox
import com.artemchep.keyguard.ui.icons.KeyguardAttachment
+import com.artemchep.keyguard.ui.icons.KeyguardAuthReprompt
+import com.artemchep.keyguard.ui.icons.KeyguardCipherFilter
+import com.artemchep.keyguard.ui.icons.KeyguardFailedItems
+import com.artemchep.keyguard.ui.icons.KeyguardIgnoredAlerts
+import com.artemchep.keyguard.ui.icons.KeyguardPendingSyncItems
import com.artemchep.keyguard.ui.icons.KeyguardTwoFa
+import com.artemchep.keyguard.ui.icons.icon
+import com.artemchep.keyguard.ui.icons.iconSmall
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
@@ -66,10 +84,54 @@ private fun mapCiphers(
data class CreateFilterResult(
val filterFlow: Flow,
val onToggle: (String, Set) -> Unit,
+ val onApply: (Map>) -> Unit,
val onClear: () -> Unit,
+ val onSave: (Map>) -> Unit,
)
-suspend fun RememberStateFlowScope.createFilter(): CreateFilterResult {
+enum class FilterSection(
+ val id: String,
+ val title: TextHolder,
+) {
+ CUSTOM(
+ id = "custom",
+ title = TextHolder.Res(Res.strings.custom),
+ ),
+ ACCOUNT(
+ id = "account",
+ title = TextHolder.Res(Res.strings.account),
+ ),
+ ORGANIZATION(
+ id = "organization",
+ title = TextHolder.Res(Res.strings.organization),
+ ),
+ TYPE(
+ id = "type",
+ title = TextHolder.Res(Res.strings.type),
+ ),
+ FOLDER(
+ id = "folder",
+ title = TextHolder.Res(Res.strings.folder),
+ ),
+ COLLECTION(
+ id = "collection",
+ title = TextHolder.Res(Res.strings.collection),
+ ),
+ MISC(
+ id = "misc",
+ title = TextHolder.Res(Res.strings.misc),
+ ),
+}
+
+enum class FilterZzz {
+
+}
+
+suspend fun RememberStateFlowScope.createFilter(
+ directDI: DirectDI,
+): CreateFilterResult {
+ val addCipherFilter: AddCipherFilter = directDI.instance()
+
val emptyState = FilterHolder(
state = mapOf(),
)
@@ -86,6 +148,25 @@ suspend fun RememberStateFlowScope.createFilter(): CreateFilterResult {
val onClear = {
filterSink.value = emptyState
}
+ val onSave = { state: Map> ->
+ val intent = createConfirmationDialogIntent(
+ item = ConfirmationRoute.Args.Item.StringItem(
+ key = "name",
+ title = translate(Res.strings.generic_name),
+ canBeEmpty = false,
+ ),
+ icon = icon(Icons.Outlined.KeyguardCipherFilter, Icons.Outlined.Add),
+ title = translate(Res.strings.customfilters_add_filter_title),
+ ) { name ->
+ val request = AddCipherFilterRequest(
+ name = name,
+ filter = state,
+ )
+ addCipherFilter(request)
+ .launchIn(appScope)
+ }
+ navigate(intent)
+ }
val onToggle = { sectionId: String, filters: Set ->
filterSink.update { holder ->
val activeFilters = holder.state.getOrElse(sectionId) { emptySet() }
@@ -105,10 +186,25 @@ suspend fun RememberStateFlowScope.createFilter(): CreateFilterResult {
)
}
}
+ val onApply = { state: Map> ->
+ filterSink.update { holder ->
+ if (holder.state == state) {
+ // Reset the filters if you click on the
+ // same item.
+ return@update emptyState
+ }
+
+ holder.copy(
+ state = state,
+ )
+ }
+ }
return CreateFilterResult(
filterFlow = filterSink,
onToggle = onToggle,
+ onApply = onApply,
onClear = onClear,
+ onSave = onSave,
)
}
@@ -116,6 +212,7 @@ data class OurFilterResult(
val rev: Int = 0,
val items: List = emptyList(),
val onClear: (() -> Unit)? = null,
+ val onSave: (() -> Unit)? = null,
)
data class FilterParams(
@@ -158,6 +255,8 @@ suspend fun <
input: CreateFilterResult,
params: FilterParams = FilterParams(),
): Flow {
+ val getCipherFilters: GetCipherFilters = directDI.instance()
+
val storage = kotlin.run {
val disk = loadDiskHandle("ciphers.filter")
PersistedStorage.InDisk(disk)
@@ -213,17 +312,12 @@ suspend fun <
sectionId: String,
sectionTitle: String,
collapse: Boolean = true,
+ checked: (FilterItem.Item, FilterHolder) -> Boolean,
) = this
.combine(input.filterFlow) { items, filterHolder ->
items
.map { item ->
- val shouldBeChecked = kotlin.run {
- val activeFilters = filterHolder.state[item.filterSectionId].orEmpty()
- item.filters
- .all { itemFilter ->
- itemFilter in activeFilters
- }
- }
+ val shouldBeChecked = checked(item, filterHolder)
if (shouldBeChecked == item.checked) {
return@map item
}
@@ -233,7 +327,7 @@ suspend fun <
}
.distinctUntilChanged()
.map { items ->
- if (items.size <= 1 && collapse) {
+ if (items.size <= 1 && collapse || items.isEmpty()) {
// Do not show a single filter item.
return@map emptyList()
}
@@ -253,15 +347,43 @@ suspend fun <
}
}
- val setOfNull = setOf(null)
+ fun Flow
>.aaa(
+ sectionId: String,
+ sectionTitle: String,
+ collapse: Boolean = true,
+ ) = aaa(
+ sectionId = sectionId,
+ sectionTitle = sectionTitle,
+ collapse = collapse,
+ checked = { item, filterHolder ->
+ when (item.filter) {
+ is FilterItem.Item.Filter.Toggle -> {
+ val activeFilters = filterHolder.state[item.filterSectionId].orEmpty()
+ item.filter.filters
+ .all { itemFilter ->
+ itemFilter in activeFilters
+ }
+ }
- val customSectionId = "custom"
- val typeSectionId = "type"
- val accountSectionId = "account"
- val folderSectionId = "folder"
- val collectionSectionId = "collection"
- val organizationSectionId = "organization"
- val miscSectionId = "misc"
+ is FilterItem.Item.Filter.Apply -> kotlin.run {
+ // If the size of the current state and the item
+ // state is different then there's no way it's currently
+ // selected.
+ if (filterHolder.state.size != item.filter.filters.size) {
+ return@run false
+ }
+
+ item.filter.filters
+ .all { (filterSectionId, filterSet) ->
+ val activeFilters = filterHolder.state[filterSectionId].orEmpty()
+ activeFilters == filterSet
+ }
+ }
+ }
+ },
+ )
+
+ val setOfNull = setOf(null)
fun createFilterAction(
sectionId: String,
@@ -276,7 +398,9 @@ suspend fun <
) = FilterItem.Item(
sectionId = sectionId,
filterSectionId = filterSectionId,
- filters = filter,
+ filter = FilterItem.Item.Filter.Toggle(
+ filters = filter,
+ ),
leading = when {
icon != null -> {
// composable
@@ -325,7 +449,7 @@ suspend fun <
tint: AccentColors? = null,
icon: ImageVector? = null,
) = createFilterAction(
- sectionId = accountSectionId,
+ sectionId = FilterSection.ACCOUNT.id,
filter = accountIds
.asSequence()
.map { accountId ->
@@ -343,10 +467,10 @@ suspend fun <
fun createTypeFilterAction(
type: DSecret.Type,
- sectionId: String = typeSectionId,
+ sectionId: String = FilterSection.TYPE.id,
) = createFilterAction(
sectionId = sectionId,
- filterSectionId = typeSectionId,
+ filterSectionId = FilterSection.TYPE.id,
filter = setOf(
DFilter.ByType(type),
),
@@ -361,7 +485,7 @@ suspend fun <
fill: Boolean,
indent: Int,
) = createFilterAction(
- sectionId = folderSectionId,
+ sectionId = FilterSection.FOLDER.id,
filter = folderIds
.asSequence()
.map { folderId ->
@@ -382,7 +506,7 @@ suspend fun <
title: String,
icon: ImageVector? = null,
) = createFilterAction(
- sectionId = collectionSectionId,
+ sectionId = FilterSection.COLLECTION.id,
filter = collectionIds
.asSequence()
.map { collectionId ->
@@ -401,7 +525,7 @@ suspend fun <
title: String,
icon: ImageVector? = null,
) = createFilterAction(
- sectionId = organizationSectionId,
+ sectionId = FilterSection.ORGANIZATION.id,
filter = organizationIds
.asSequence()
.map { organizationId ->
@@ -432,7 +556,8 @@ suspend fun <
.combine(filterAccountsWithCiphers) { items, accountIds ->
items
.filter { filterItem ->
- filterItem.filters
+ val filterItemFilter = filterItem.filter as FilterItem.Item.Filter.Toggle
+ filterItemFilter.filters
.any { filter ->
val filterFixed = filter as DFilter.ById
require(filterFixed.what == DFilter.ById.What.ACCOUNT)
@@ -441,8 +566,8 @@ suspend fun <
}
}
.aaa(
- sectionId = accountSectionId,
- sectionTitle = translate(Res.strings.account),
+ sectionId = FilterSection.ACCOUNT.id,
+ sectionTitle = translate(FilterSection.ACCOUNT.title),
)
.filterSection(params.section.account)
@@ -468,8 +593,8 @@ suspend fun <
}
}
.aaa(
- sectionId = typeSectionId,
- sectionTitle = translate(Res.strings.type),
+ sectionId = FilterSection.TYPE.id,
+ sectionTitle = translate(FilterSection.TYPE.title),
)
.filterSection(params.section.type)
@@ -531,7 +656,8 @@ suspend fun <
.combine(filterFoldersWithCiphers) { items, folderIds ->
items
.filter { filterItem ->
- filterItem.filters
+ val filterItemFilter = filterItem.filter as FilterItem.Item.Filter.Toggle
+ filterItemFilter.filters
.any { filter ->
val filterFixed = filter as DFilter.ById
require(filterFixed.what == DFilter.ById.What.FOLDER)
@@ -540,8 +666,8 @@ suspend fun <
}
}
.aaa(
- sectionId = folderSectionId,
- sectionTitle = translate(Res.strings.folder),
+ sectionId = FilterSection.FOLDER.id,
+ sectionTitle = translate(FilterSection.FOLDER.title),
)
.filterSection(params.section.folder)
@@ -573,7 +699,8 @@ suspend fun <
.combine(filterCollectionsWithCiphers) { items, collectionIds ->
items
.filter { filterItem ->
- filterItem.filters
+ val filterItemFilter = filterItem.filter as FilterItem.Item.Filter.Toggle
+ filterItemFilter.filters
.any { filter ->
val filterFixed = filter as DFilter.ById
require(filterFixed.what == DFilter.ById.What.COLLECTION)
@@ -582,8 +709,8 @@ suspend fun <
}
}
.aaa(
- sectionId = collectionSectionId,
- sectionTitle = translate(Res.strings.collection),
+ sectionId = FilterSection.COLLECTION.id,
+ sectionTitle = translate(FilterSection.COLLECTION.title),
)
.filterSection(params.section.collection)
@@ -615,7 +742,8 @@ suspend fun <
.combine(filterOrganizationsWithCiphers) { items, organizationIds ->
items
.filter { filterItem ->
- filterItem.filters
+ val filterItemFilter = filterItem.filter as FilterItem.Item.Filter.Toggle
+ filterItemFilter.filters
.any { filter ->
val filterFixed = filter as DFilter.ById
require(filterFixed.what == DFilter.ById.What.ORGANIZATION)
@@ -624,74 +752,74 @@ suspend fun <
}
}
.aaa(
- sectionId = organizationSectionId,
- sectionTitle = translate(Res.strings.organization),
+ sectionId = FilterSection.ORGANIZATION.id,
+ sectionTitle = translate(FilterSection.ORGANIZATION.title),
)
.filterSection(params.section.organization)
val filterMiscAll = listOf(
createFilterAction(
- sectionId = miscSectionId,
+ sectionId = FilterSection.MISC.id,
filter = setOf(
DFilter.ByOtp,
),
- filterSectionId = "$miscSectionId.otp",
- title = translate(Res.strings.one_time_password),
- icon = Icons.Outlined.KeyguardTwoFa,
+ filterSectionId = "${FilterSection.MISC.id}.otp",
+ title = translate(DFilter.ByOtp.content.title),
+ icon = DFilter.ByOtp.content.icon,
),
createFilterAction(
- sectionId = miscSectionId,
+ sectionId = FilterSection.MISC.id,
filter = setOf(
DFilter.ByAttachments,
),
- filterSectionId = "$miscSectionId.attachments",
- title = translate(Res.strings.attachments),
- icon = Icons.Outlined.KeyguardAttachment,
+ filterSectionId = "${FilterSection.MISC.id}.attachments",
+ title = translate(DFilter.ByAttachments.content.title),
+ icon = DFilter.ByAttachments.content.icon,
),
createFilterAction(
- sectionId = miscSectionId,
+ sectionId = FilterSection.MISC.id,
filter = setOf(
DFilter.ByPasskeys,
),
- filterSectionId = "$miscSectionId.passkeys",
- title = translate(Res.strings.passkeys),
- icon = Icons.Outlined.Key,
+ filterSectionId = "${FilterSection.MISC.id}.passkeys",
+ title = translate(DFilter.ByPasskeys.content.title),
+ icon = DFilter.ByPasskeys.content.icon,
),
createFilterAction(
- sectionId = miscSectionId,
+ sectionId = FilterSection.MISC.id,
filter = setOf(
DFilter.ByReprompt(reprompt = true),
),
- filterSectionId = "$miscSectionId.reprompt",
- title = "Auth re-prompt",
- icon = Icons.Outlined.Lock,
+ filterSectionId = "${FilterSection.MISC.id}.reprompt",
+ title = translate(Res.strings.filter_auth_reprompt_items),
+ icon = Icons.Outlined.KeyguardAuthReprompt,
),
createFilterAction(
- sectionId = miscSectionId,
+ sectionId = FilterSection.MISC.id,
filter = setOf(
DFilter.BySync(synced = false),
),
- filterSectionId = "$miscSectionId.sync",
- title = "Un-synced",
- icon = Icons.Outlined.CloudOff,
+ filterSectionId = "${FilterSection.MISC.id}.sync",
+ title = translate(Res.strings.filter_pending_items),
+ icon = Icons.Outlined.KeyguardPendingSyncItems,
),
createFilterAction(
- sectionId = miscSectionId,
+ sectionId = FilterSection.MISC.id,
filter = setOf(
DFilter.ByError(error = true),
),
- filterSectionId = "$miscSectionId.error",
- title = "Failed",
- icon = Icons.Outlined.ErrorOutline,
+ filterSectionId = "${FilterSection.MISC.id}.error",
+ title = translate(Res.strings.filter_failed_items),
+ icon = Icons.Outlined.KeyguardFailedItems,
),
createFilterAction(
- sectionId = miscSectionId,
+ sectionId = FilterSection.MISC.id,
filter = setOf(
DFilter.ByIgnoredAlerts,
),
- filterSectionId = "$miscSectionId.watchtower_alerts",
+ filterSectionId = "${FilterSection.MISC.id}.watchtower_alerts",
title = translate(Res.strings.ignored_alerts),
- icon = Icons.Outlined.NotificationsOff,
+ icon = Icons.Outlined.KeyguardIgnoredAlerts,
),
)
@@ -700,59 +828,51 @@ suspend fun <
filterMiscAll
}
.aaa(
- sectionId = miscSectionId,
- sectionTitle = translate(Res.strings.misc),
+ sectionId = FilterSection.MISC.id,
+ sectionTitle = translate(FilterSection.MISC.title),
collapse = false,
)
.filterSection(params.section.misc)
- val filterCustomTypesAll = listOf(
- DSecret.Type.Login to createTypeFilterAction(
- sectionId = customSectionId,
- type = DSecret.Type.Login,
- ),
- DSecret.Type.Card to createTypeFilterAction(
- sectionId = customSectionId,
- type = DSecret.Type.Card,
- ),
- DSecret.Type.Identity to createTypeFilterAction(
- sectionId = customSectionId,
- type = DSecret.Type.Identity,
- ),
- DSecret.Type.SecureNote to createTypeFilterAction(
- sectionId = customSectionId,
- type = DSecret.Type.SecureNote,
- ),
- )
-
- val filterCustomAll = listOf(
- createFilterAction(
- sectionId = customSectionId,
- filter = setOf(
- DFilter.ByOtp,
- ),
- filterSectionId = "$miscSectionId.otp",
- title = translate(Res.strings.one_time_password),
- icon = Icons.Outlined.KeyguardTwoFa,
- ),
- )
-
- if (params.deeplinkCustomFilter == "2fa") {
- input.onToggle(
- "$miscSectionId.otp",
- setOf(
- DFilter.ByOtp,
- ),
- )
+ if (params.deeplinkCustomFilter != null) {
+ val customFilter = kotlin.run {
+ val customFilters = getCipherFilters()
+ .first()
+ customFilters.firstOrNull { it.id == params.deeplinkCustomFilter }
+ }
+ if (customFilter != null) {
+ input.onApply(customFilter.filter)
+ }
}
- val filterCustomListFlow = flowOf(Unit)
- .map {
- filterCustomTypesAll.map { it.second } + filterCustomAll
+ val filterCustomListFlow = getCipherFilters()
+ .map { filters ->
+ filters
+ .map { filter ->
+ FilterItem.Item(
+ sectionId = FilterSection.CUSTOM.id,
+ filterSectionId = FilterSection.CUSTOM.id,
+ filter = FilterItem.Item.Filter.Apply(
+ filters = filter.filter,
+ id = filter.id,
+ ),
+ leading = filter.icon
+ ?.let {
+ iconSmall(it)
+ },
+ title = filter.name,
+ text = null,
+ onClick = input.onApply
+ .partially1(filter.filter),
+ fill = false,
+ indent = 0,
+ checked = false,
+ )
+ }
}
.aaa(
- sectionId = customSectionId,
- sectionTitle = translate(Res.strings.custom),
+ sectionId = FilterSection.CUSTOM.id,
+ sectionTitle = translate(FilterSection.CUSTOM.title),
collapse = false,
)
.filterSection(params.section.custom)
@@ -795,7 +915,7 @@ suspend fun <
when (it) {
is FilterItem.Section -> null
is FilterItem.Item -> it.takeIf { it.checked }
- ?.sectionId
+ ?.filterSectionId
}
}
.toSet()
@@ -808,9 +928,11 @@ suspend fun <
val fastEnabled = item.checked ||
// If one of the items in a section is enabled, then
// enable the whole section.
- item.sectionId in checkedSectionIds
+ item.filterSectionId in checkedSectionIds
val enabled = fastEnabled || kotlin.run {
- item.filters
+ val filterItemFilter = item.filter as? FilterItem.Item.Filter.Toggle
+ ?: return@run true
+ filterItemFilter.filters
.any { filter ->
val filterPredicate = filter.prepare(directDI, outputCiphers)
outputCiphers.any(filterPredicate)
@@ -822,7 +944,10 @@ suspend fun <
return@forEach
}
- out += item.copy(onClick = null)
+ out += item.copy(
+ onClick = null,
+ enabled = false,
+ )
}
}
}
@@ -833,6 +958,9 @@ suspend fun <
rev = b.id,
items = a,
onClear = input.onClear.takeIf { b.id != 0 },
+ onSave = input.onSave
+ .takeIf { b.id != 0 }
+ ?.partially1(b.state),
)
}
.flowOn(Dispatchers.Default)
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListItemMapping.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListItemMapping.kt
index 9d0aefee..c45a0d58 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListItemMapping.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListItemMapping.kt
@@ -22,6 +22,7 @@ import com.artemchep.keyguard.feature.home.vault.component.VaultViewTotpBadge
import com.artemchep.keyguard.feature.home.vault.component.obscureCardNumber
import com.artemchep.keyguard.feature.home.vault.model.VaultItem2
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
+import com.artemchep.keyguard.feature.home.vault.model.short
import com.artemchep.keyguard.feature.navigation.state.TranslatorScope
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.FlatItemAction
@@ -166,9 +167,7 @@ fun DSecret.toVaultItemIcon(
}
}
val textIcon = if (name.isNotBlank()) {
- VaultItemIcon.TextIcon(
- text = name.take(2),
- )
+ VaultItemIcon.TextIcon.short(name)
} else {
null
}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListScreen.kt
index 21023f02..07d06ec6 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListScreen.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListScreen.kt
@@ -223,19 +223,13 @@ private fun VaultListFilterScreen(
val count = (state.content as? VaultListState.Content.Items)?.count
val filters = state.filters
val clearFilters = state.clearFilters
+ val saveFilters = state.saveFilters
FilterScreen(
modifier = modifier,
count = count,
items = filters,
onClear = clearFilters,
- actions = {
- VaultListSortButton(
- state = state,
- )
- OptionsButton(
- actions = state.actions,
- )
- },
+ onSave = saveFilters,
)
}
@@ -247,11 +241,13 @@ private fun VaultListFilterButton(
val count = (state.content as? VaultListState.Content.Items)?.count
val filters = state.filters
val clearFilters = state.clearFilters
+ val saveFilters = state.saveFilters
FilterButton(
modifier = modifier,
count = count,
items = filters,
onClear = clearFilters,
+ onSave = saveFilters,
)
}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListState.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListState.kt
index 294a5a73..2e3761c1 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListState.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListState.kt
@@ -21,6 +21,7 @@ data class VaultListState(
val query: TextFieldModel2 = TextFieldModel2(mutableStateOf("")),
val filters: List = emptyList(),
val sort: List = emptyList(),
+ val saveFilters: (() -> Unit)? = null,
val clearFilters: (() -> Unit)? = null,
val clearSort: (() -> Unit)? = null,
val selectCipher: ((DSecret) -> Unit)? = null,
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListStateProducer.kt
index 85e0fc73..f83b0482 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListStateProducer.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/screen/VaultListStateProducer.kt
@@ -76,6 +76,7 @@ import com.artemchep.keyguard.feature.decorator.ItemDecoratorDate
import com.artemchep.keyguard.feature.decorator.ItemDecoratorNone
import com.artemchep.keyguard.feature.decorator.ItemDecoratorTitle
import com.artemchep.keyguard.feature.duplicates.list.createCipherSelectionFlow
+import com.artemchep.keyguard.feature.filter.CipherFiltersRoute
import com.artemchep.keyguard.feature.generator.history.mapLatestScoped
import com.artemchep.keyguard.feature.home.vault.VaultRoute
import com.artemchep.keyguard.feature.home.vault.add.AddRoute
@@ -387,7 +388,7 @@ fun vaultListScreenState(
var scrollPositionKey: Any? = null
val scrollPositionSink = mutablePersistedFlow("scroll_state") { OhOhOh() }
- val filterResult = createFilter()
+ val filterResult = createFilter(directDI)
val actionsFlow = kotlin.run {
val actionTrashItem = FlatItemAction(
leading = {
@@ -429,9 +430,15 @@ fun vaultListScreenState(
},
)
val actionDownloadsFlow = flowOf(actionDownloadsItem)
+ val actionFiltersItem = CipherFiltersRoute.actionOrNull(
+ translator = this,
+ navigate = ::navigate,
+ )
+ val actionFiltersFlow = flowOf(actionFiltersItem)
val actionGroupFlow = combine(
actionTrashFlow,
actionDownloadsFlow,
+ actionFiltersFlow,
) { array ->
buildContextItems {
section {
@@ -1223,15 +1230,15 @@ fun vaultListScreenState(
.map {
val keepOtp = it.filterConfig?.filter
?.let {
- DFilter.findOne(it, DFilter.ByOtp::class.java)
+ DFilter.findAny(it)
} != null
val keepAttachment = it.filterConfig?.filter
?.let {
- DFilter.findOne(it, DFilter.ByAttachments::class.java)
+ DFilter.findAny(it)
} != null
val keepPasskey = it.filterConfig?.filter
?.let {
- DFilter.findOne(it, DFilter.ByPasskeys::class.java)
+ DFilter.findAny(it)
} != null ||
// If a user is in the pick a passkey mode,
// then we want to always show it in the items.
@@ -1511,6 +1518,7 @@ fun vaultListScreenState(
} else {
emptyList()
},
+ saveFilters = filters.onSave,
clearFilters = filters.onClear,
clearSort = if (sortDefault != sort) {
{
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/component/DropdownButton.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/component/DropdownButton.kt
index 5d40d8ae..34635030 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/component/DropdownButton.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/component/DropdownButton.kt
@@ -9,6 +9,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Clear
+import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -18,6 +21,7 @@ import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -34,6 +38,7 @@ fun DropdownButton(
title: String,
items: List,
onClear: (() -> Unit)?,
+ onSave: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
var isExpanded by remember { mutableStateOf(false) }
@@ -44,6 +49,18 @@ fun DropdownButton(
isExpanded = false
}
}
+ val onExpandRequest = remember {
+ // lambda
+ {
+ isExpanded = true
+ }
+ }
+ val onDismissRequest = remember {
+ // lambda
+ {
+ isExpanded = false
+ }
+ }
Box(
modifier = modifier,
@@ -55,9 +72,7 @@ fun DropdownButton(
IconButton(
modifier = Modifier,
enabled = items.isNotEmpty(),
- onClick = {
- isExpanded = !isExpanded
- },
+ onClick = onExpandRequest,
) {
Box {
Icon(icon, null)
@@ -82,9 +97,6 @@ fun DropdownButton(
// Inject the dropdown popup to the bottom of the
// content.
- val onDismissRequest = {
- isExpanded = false
- }
DropdownMenu(
modifier = Modifier
.widthIn(
@@ -99,7 +111,43 @@ fun DropdownButton(
modifier = Modifier
.fillMaxWidth(),
title = title,
- onClear = onClear,
+ actions = {
+ val updatedOnSave by rememberUpdatedState(onSave)
+ ExpandedIfNotEmptyForRow(
+ valueOrNull = onSave,
+ ) {
+ IconButton(
+ onClick = {
+ // At first we hide the popup
+ // menu, because it launches a
+ // new route.
+ onDismissRequest()
+ // Launch the screen.
+ updatedOnSave?.invoke()
+ },
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Save,
+ contentDescription = null,
+ )
+ }
+ }
+ val updatedOnClear by rememberUpdatedState(onClear)
+ ExpandedIfNotEmptyForRow(
+ valueOrNull = onClear,
+ ) {
+ IconButton(
+ onClick = {
+ updatedOnClear?.invoke()
+ },
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Clear,
+ contentDescription = null,
+ )
+ }
+ }
+ },
)
content()
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/component/DropdownHeader.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/component/DropdownHeader.kt
index 85628528..29fbaf62 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/component/DropdownHeader.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/component/DropdownHeader.kt
@@ -1,32 +1,24 @@
package com.artemchep.keyguard.feature.search.component
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Clear
-import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
-import com.artemchep.keyguard.res.Res
-import com.artemchep.keyguard.ui.ExpandedIfNotEmptyForRow
import com.artemchep.keyguard.ui.theme.Dimens
-import dev.icerock.moko.resources.compose.stringResource
@Composable
fun DropdownHeader(
title: String,
- onClear: (() -> Unit)?,
modifier: Modifier = Modifier,
+ actions: @Composable RowScope.() -> Unit,
) {
Row(
modifier = modifier
@@ -40,29 +32,11 @@ fun DropdownHeader(
text = title,
style = MaterialTheme.typography.titleMedium,
)
-
- val updatedOnClear by rememberUpdatedState(onClear)
- ExpandedIfNotEmptyForRow(
- valueOrNull = onClear,
- ) {
- TextButton(
- onClick = {
- updatedOnClear?.invoke()
- },
- ) {
- Icon(
- imageVector = Icons.Outlined.Clear,
- contentDescription = null,
- )
- Spacer(
- modifier = Modifier
- .width(Dimens.buttonIconPadding),
- )
- Text(
- text = stringResource(Res.strings.reset),
- )
- }
- }
+ Spacer(
+ modifier = Modifier
+ .width(8.dp),
+ )
+ actions()
Spacer(
modifier = Modifier
.width(8.dp),
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/FilterButton.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/FilterButton.kt
index 1e68772d..7509d75f 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/FilterButton.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/FilterButton.kt
@@ -16,6 +16,7 @@ fun FilterButton(
count: Int?,
items: List,
onClear: (() -> Unit)?,
+ onSave: (() -> Unit)?,
) {
DropdownButton(
modifier = modifier,
@@ -23,6 +24,7 @@ fun FilterButton(
title = stringResource(Res.strings.filter_header_title),
items = items,
onClear = onClear,
+ onSave = onSave,
) {
VaultHomeScreenFilterPaneNumberOfItems(
count = count,
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/FilterItems.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/FilterItems.kt
index d541bb4d..7ea03fc6 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/FilterItems.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/FilterItems.kt
@@ -67,6 +67,7 @@ private fun FilterItemItem(
},
),
checked = item.checked,
+ enabled = item.enabled,
leading = item.leading,
title = item.title,
text = item.text,
@@ -79,6 +80,7 @@ private fun FilterItemSection(
item: FilterItemModel.Section,
) {
FilterSectionComposable(
+ expandable = item.expandable,
expanded = item.expanded,
title = item.text,
onClick = item.onClick,
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/FilterScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/FilterScreen.kt
index 84bf3d5f..3ff20b0c 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/FilterScreen.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/FilterScreen.kt
@@ -1,23 +1,28 @@
package com.artemchep.keyguard.feature.search.filter
-import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Clear
+import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.unit.dp
import com.artemchep.keyguard.feature.search.filter.component.VaultHomeScreenFilterPaneNumberOfItems
import com.artemchep.keyguard.feature.search.filter.model.FilterItemModel
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.DefaultFab
import com.artemchep.keyguard.ui.FabState
import com.artemchep.keyguard.ui.ScaffoldColumn
+import com.artemchep.keyguard.ui.SmallFab
import com.artemchep.keyguard.ui.icons.IconBox
-import com.artemchep.keyguard.ui.toolbar.SmallToolbar
import dev.icerock.moko.resources.compose.stringResource
@Composable
@@ -26,25 +31,13 @@ fun FilterScreen(
count: Int?,
items: List,
onClear: (() -> Unit)?,
- actions: @Composable RowScope.() -> Unit,
+ onSave: (() -> Unit)? = null,
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
ScaffoldColumn(
modifier = modifier
.nestedScroll(scrollBehavior.nestedScrollConnection),
topAppBarScrollBehavior = scrollBehavior,
-// topBar = {
-// SmallToolbar(
-// title = {
-// Text(
-// text = stringResource(Res.strings.filter_header_title),
-// style = MaterialTheme.typography.titleMedium,
-// )
-// },
-// actions = actions,
-// scrollBehavior = scrollBehavior,
-// )
-// },
floatingActionState = run {
val fabState = if (onClear != null) {
FabState(
@@ -57,17 +50,31 @@ fun FilterScreen(
rememberUpdatedState(newValue = fabState)
},
floatingActionButton = {
- DefaultFab(
- icon = {
- IconBox(main = Icons.Outlined.Clear)
- },
- text = {
- Text(
- text = stringResource(Res.strings.reset),
- )
- },
- color = MaterialTheme.colorScheme.secondaryContainer,
- )
+ Column(
+ horizontalAlignment = Alignment.End,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ val updatedOnSave by rememberUpdatedState(onSave)
+ SmallFab(
+ onClick = {
+ updatedOnSave?.invoke()
+ },
+ icon = {
+ IconBox(main = Icons.Outlined.Save)
+ },
+ )
+ DefaultFab(
+ icon = {
+ IconBox(main = Icons.Outlined.Clear)
+ },
+ text = {
+ Text(
+ text = stringResource(Res.strings.reset),
+ )
+ },
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ )
+ }
},
) {
VaultHomeScreenFilterPaneNumberOfItems(
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/component/FilterItem.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/component/FilterItem.kt
index c2e22469..ebb42191 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/component/FilterItem.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/component/FilterItem.kt
@@ -42,6 +42,7 @@ fun FilterItemComposable(
title: String,
text: String?,
onClick: (() -> Unit)?,
+ enabled: Boolean = onClick != null,
) {
FilterItemLayout(
modifier = modifier,
@@ -67,6 +68,7 @@ fun FilterItemComposable(
}
},
onClick = onClick,
+ enabled = enabled,
)
}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/component/FilterSection.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/component/FilterSection.kt
index 794dbca7..2566d7c8 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/component/FilterSection.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/component/FilterSection.kt
@@ -22,6 +22,7 @@ import com.artemchep.keyguard.ui.theme.combineAlpha
@Composable
fun FilterSectionComposable(
modifier: Modifier = Modifier,
+ expandable: Boolean,
expanded: Boolean,
title: String,
onClick: (() -> Unit)?,
@@ -33,6 +34,10 @@ fun FilterSectionComposable(
vertical = 0.dp,
),
trailing = {
+ if (!expandable) {
+ return@FlatItem
+ }
+
val targetRotationX =
if (expanded) {
0f
@@ -63,5 +68,6 @@ fun FilterSectionComposable(
)
},
onClick = onClick,
+ enabled = true,
)
}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/model/FilterItemModel.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/model/FilterItemModel.kt
index a612e362..61559ab0 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/model/FilterItemModel.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/search/filter/model/FilterItemModel.kt
@@ -7,8 +7,9 @@ interface FilterItemModel {
interface Section : FilterItemModel {
val text: String
+ val expandable: Boolean
val expanded: Boolean
- val onClick: () -> Unit
+ val onClick: (() -> Unit)?
}
interface Item : FilterItemModel {
@@ -16,6 +17,7 @@ interface FilterItemModel {
val title: String
val text: String?
val checked: Boolean
+ val enabled: Boolean
val fill: Boolean
val indent: Int
val onClick: (() -> Unit)?
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/SendListState.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/SendListState.kt
index 39900c5c..6c22c2cf 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/SendListState.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/SendListState.kt
@@ -19,6 +19,7 @@ data class SendListState(
val query: TextFieldModel2 = TextFieldModel2(mutableStateOf("")),
val filters: List = emptyList(),
val sort: List = emptyList(),
+ val saveFilters: (() -> Unit)? = null,
val clearFilters: (() -> Unit)? = null,
val clearSort: (() -> Unit)? = null,
val showKeyboard: Boolean = false,
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/SendListStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/SendListStateProducer.kt
index c48169e7..c589200c 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/SendListStateProducer.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/SendListStateProducer.kt
@@ -843,6 +843,7 @@ fun sendListScreenState(
.takeIf { queryTrimmed.isEmpty() }
.orEmpty(),
primaryActions = primaryActions,
+ saveFilters = null,
clearFilters = filters.onClear,
clearSort = if (sortDefault != sort) {
{
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/list/SendListScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/list/SendListScreen.kt
index 9e22bd0e..6ec9572a 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/list/SendListScreen.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/list/SendListScreen.kt
@@ -346,14 +346,6 @@ private fun SendListFilterScreen(
count = count,
items = filters,
onClear = clearFilters,
- actions = {
- SendListSortButton(
- state = state,
- )
- OptionsButton(
- actions = state.actions,
- )
- },
)
}
@@ -365,11 +357,13 @@ private fun SendListFilterButton(
val count = (state.content as? SendListState.Content.Items)?.count
val filters = state.filters
val clearFilters = state.clearFilters
+ val saveFilters = state.saveFilters
FilterButton(
modifier = modifier,
count = count,
items = filters,
onClear = clearFilters,
+ onSave = saveFilters,
)
}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/search/filter/FilterItem.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/search/filter/FilterItem.kt
index 4cff8bd0..e8648a9a 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/search/filter/FilterItem.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/search/filter/FilterItem.kt
@@ -14,8 +14,9 @@ sealed interface SendFilterItem : FilterItemModel {
data class Section(
override val sectionId: String,
override val text: String,
+ override val expandable: Boolean = true,
override val expanded: Boolean = true,
- override val onClick: () -> Unit,
+ override val onClick: (() -> Unit)?,
) : SendFilterItem, FilterItemModel.Section {
companion object;
@@ -33,6 +34,7 @@ sealed interface SendFilterItem : FilterItemModel {
override val title: String,
override val text: String?,
override val onClick: (() -> Unit)?,
+ override val enabled: Boolean = onClick != null,
) : SendFilterItem, FilterItemModel.Item {
companion object;
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListStateProducer.kt
index 542dea56..d786b5aa 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListStateProducer.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/urloverride/UrlOverrideListStateProducer.kt
@@ -24,6 +24,7 @@ import com.artemchep.keyguard.feature.confirmation.ConfirmationRoute
import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogIntent
import com.artemchep.keyguard.feature.crashlytics.crashlyticsAttempt
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
+import com.artemchep.keyguard.feature.home.vault.model.short
import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver
import com.artemchep.keyguard.feature.navigation.state.produceScreenState
@@ -285,18 +286,7 @@ fun produceUrlOverrideListState(
)
}
}
- val icon = VaultItemIcon.TextIcon(
- run {
- val words = it.name.split(" ")
- if (words.size <= 1) {
- return@run words.firstOrNull()?.take(2).orEmpty()
- }
-
- words
- .take(2)
- .joinToString("") { it.take(1) }
- }.uppercase(),
- )
+ val icon = VaultItemIcon.TextIcon.short(it.name)
val selectableFlow = selectionHandle
.idsFlow
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/watchtower/WatchtowerScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/watchtower/WatchtowerScreen.kt
index a34c4db7..9eebbb94 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/watchtower/WatchtowerScreen.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/watchtower/WatchtowerScreen.kt
@@ -33,12 +33,10 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AccountTree
import androidx.compose.material.icons.outlined.CheckCircleOutline
import androidx.compose.material.icons.outlined.CopyAll
-import androidx.compose.material.icons.outlined.DataArray
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.FolderOff
import androidx.compose.material.icons.outlined.Info
-import androidx.compose.material.icons.outlined.Recycling
import androidx.compose.material.icons.outlined.ShortText
import androidx.compose.material.icons.outlined.Timer
import androidx.compose.material.icons.outlined.Warning
@@ -99,7 +97,17 @@ import com.artemchep.keyguard.ui.GridLayout
import com.artemchep.keyguard.ui.OptionsButton
import com.artemchep.keyguard.ui.animatedNumberText
import com.artemchep.keyguard.ui.grid.preferredGridWidth
+import com.artemchep.keyguard.ui.icons.KeyguardDuplicateItems
+import com.artemchep.keyguard.ui.icons.KeyguardDuplicateWebsites
+import com.artemchep.keyguard.ui.icons.KeyguardExpiringItems
+import com.artemchep.keyguard.ui.icons.KeyguardIncompleteItems
+import com.artemchep.keyguard.ui.icons.KeyguardPasskey
+import com.artemchep.keyguard.ui.icons.KeyguardPwnedPassword
+import com.artemchep.keyguard.ui.icons.KeyguardReusedPassword
import com.artemchep.keyguard.ui.icons.KeyguardTwoFa
+import com.artemchep.keyguard.ui.icons.KeyguardPwnedWebsites
+import com.artemchep.keyguard.ui.icons.KeyguardTrashedItems
+import com.artemchep.keyguard.ui.icons.KeyguardUnsecureWebsites
import com.artemchep.keyguard.ui.icons.KeyguardWebsite
import com.artemchep.keyguard.ui.poweredby.PoweredBy2factorauth
import com.artemchep.keyguard.ui.poweredby.PoweredByHaveibeenpwned
@@ -211,8 +219,6 @@ fun VaultHomeScreenFilterPaneCard2(
count = null,
items = items,
onClear = onClear,
- actions = {
- },
)
}
@@ -397,6 +403,7 @@ private fun VaultHomeScreenFilterButton(
modifier = modifier,
items = state.filter.items,
onClear = state.filter.onClear,
+ onSave = state.filter.onSave,
)
}
@@ -405,12 +412,14 @@ fun VaultHomeScreenFilterButton2(
modifier: Modifier = Modifier,
items: List,
onClear: (() -> Unit)?,
+ onSave: (() -> Unit)?,
) {
FilterButton(
modifier = modifier,
count = null,
items = items,
onClear = onClear,
+ onSave = onSave,
)
}
@@ -701,7 +710,7 @@ private fun CardPwnedPassword(
)
},
text = stringResource(Res.strings.watchtower_item_pwned_passwords_text),
- imageVector = Icons.Outlined.DataArray,
+ imageVector = Icons.Outlined.KeyguardPwnedPassword,
onClick = state.onClick,
content = {
PoweredByHaveibeenpwned(
@@ -727,7 +736,7 @@ private fun CardReusedPassword(
)
},
text = stringResource(Res.strings.watchtower_item_reused_passwords_text),
- imageVector = Icons.Outlined.Recycling,
+ imageVector = Icons.Outlined.KeyguardReusedPassword,
onClick = state.onClick,
)
}
@@ -746,7 +755,7 @@ private fun CardVulnerableAccounts(
)
},
text = stringResource(Res.strings.watchtower_item_vulnerable_accounts_text),
- imageVector = Icons.Outlined.KeyguardWebsite,
+ imageVector = Icons.Outlined.KeyguardPwnedWebsites,
onClick = state.onClick,
content = {
PoweredByHaveibeenpwned(
@@ -772,7 +781,7 @@ private fun CardUnsecureWebsites(
)
},
text = stringResource(Res.strings.watchtower_item_unsecure_websites_text),
- imageVector = Icons.Outlined.KeyguardWebsite,
+ imageVector = Icons.Outlined.KeyguardUnsecureWebsites,
onClick = state.onClick,
)
}
@@ -843,7 +852,7 @@ private fun CardInactivePasskey(
)
},
text = stringResource(Res.strings.watchtower_item_inactive_passkey_text),
- imageVector = Icons.Outlined.KeyguardTwoFa,
+ imageVector = Icons.Outlined.KeyguardPasskey,
onClick = state.onClick,
content = {
PoweredByPasskeys(
@@ -869,7 +878,7 @@ private fun CardIncompleteItems(
)
},
text = stringResource(Res.strings.watchtower_item_incomplete_items_text),
- imageVector = Icons.Outlined.ShortText,
+ imageVector = Icons.Outlined.KeyguardIncompleteItems,
onClick = state.onClick,
)
}
@@ -888,7 +897,7 @@ private fun CardExpiringItems(
)
},
text = stringResource(Res.strings.watchtower_item_expiring_items_text),
- imageVector = Icons.Outlined.Timer,
+ imageVector = Icons.Outlined.KeyguardExpiringItems,
onClick = state.onClick,
)
}
@@ -907,7 +916,7 @@ private fun CardDuplicateWebsites(
)
},
text = stringResource(Res.strings.watchtower_item_duplicate_websites_text),
- imageVector = Icons.Outlined.KeyguardWebsite,
+ imageVector = Icons.Outlined.KeyguardDuplicateWebsites,
onClick = state.onClick,
)
}
@@ -934,7 +943,7 @@ private fun CardTrashedItems(
)
},
text = stringResource(Res.strings.watchtower_item_trashed_items_text),
- imageVector = Icons.Outlined.Delete,
+ imageVector = Icons.Outlined.KeyguardTrashedItems,
onClick = state.onClick,
)
}
@@ -977,7 +986,7 @@ private fun CardDuplicateItems(
)
},
text = stringResource(Res.strings.watchtower_item_duplicate_items_text),
- imageVector = Icons.Outlined.CopyAll,
+ imageVector = Icons.Outlined.KeyguardDuplicateItems,
onClick = state.onClick,
)
}
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/watchtower/WatchtowerState.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/watchtower/WatchtowerState.kt
index c2c2c5a7..5d5b430b 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/watchtower/WatchtowerState.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/watchtower/WatchtowerState.kt
@@ -14,6 +14,7 @@ data class WatchtowerState(
data class Filter(
val items: List = emptyList(),
val onClear: (() -> Unit)? = null,
+ val onSave: (() -> Unit)? = null,
)
data class Content(
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/watchtower/WatchtowerStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/watchtower/WatchtowerStateProducer.kt
index 2e25bbf3..fd6e2a17 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/watchtower/WatchtowerStateProducer.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/watchtower/WatchtowerStateProducer.kt
@@ -141,7 +141,7 @@ fun produceWatchtowerState(
}
.shareIn(screenScope, SharingStarted.WhileSubscribed(), replay = 1)
- val filterResult = createFilter()
+ val filterResult = createFilter(directDI)
fun filteredCiphers(ciphersFlow: Flow>) = ciphersFlow
.map {
@@ -993,6 +993,7 @@ fun produceWatchtowerState(
filter = WatchtowerState.Filter(
items = filterState.items,
onClear = filterState.onClear,
+ onSave = filterState.onSave,
),
actions = actions,
)
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/platform/util/hasAutofill.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/platform/util/hasAutofill.kt
index 8abf6ea1..a8a64a84 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/platform/util/hasAutofill.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/platform/util/hasAutofill.kt
@@ -8,3 +8,6 @@ fun Platform.hasAutofill(): Boolean =
fun Platform.hasSubscription(): Boolean =
this is Platform.Mobile.Android
+
+fun Platform.hasDynamicShortcuts(): Boolean =
+ this is Platform.Mobile.Android
diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/ui/icons/Icons.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/ui/icons/Icons.kt
index ebde7edc..bf0f279b 100644
--- a/common/src/commonMain/kotlin/com/artemchep/keyguard/ui/icons/Icons.kt
+++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/ui/icons/Icons.kt
@@ -5,15 +5,26 @@ import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.material.icons.outlined.AccountTree
import androidx.compose.material.icons.outlined.Attachment
-import androidx.compose.material.icons.outlined.Book
+import androidx.compose.material.icons.outlined.CloudOff
import androidx.compose.material.icons.outlined.CollectionsBookmark
+import androidx.compose.material.icons.outlined.CopyAll
+import androidx.compose.material.icons.outlined.DataArray
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.outlined.ErrorOutline
+import androidx.compose.material.icons.outlined.FilterList
+import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.outlined.Lens
import androidx.compose.material.icons.outlined.LibraryBooks
import androidx.compose.material.icons.outlined.List
+import androidx.compose.material.icons.outlined.Lock
+import androidx.compose.material.icons.outlined.NotificationsOff
import androidx.compose.material.icons.outlined.Numbers
+import androidx.compose.material.icons.outlined.Recycling
+import androidx.compose.material.icons.outlined.ShortText
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material.icons.outlined.StickyNote2
+import androidx.compose.material.icons.outlined.Timer
import androidx.compose.ui.graphics.vector.ImageVector
import compose.icons.FeatherIcons
import compose.icons.feathericons.Eye
@@ -28,6 +39,9 @@ val Icons.Outlined.KeyguardView
val Icons.Outlined.KeyguardTwoFa
get() = Numbers
+val Icons.Outlined.KeyguardPasskey
+ get() = Key
+
val Icons.Outlined.KeyguardNote
get() = StickyNote2
@@ -58,6 +72,48 @@ val Icons.Outlined.KeyguardPremium
val Icons.Outlined.KeyguardWordlist
get() = LibraryBooks
+val Icons.Outlined.KeyguardCipherFilter
+ get() = FilterList
+
+val Icons.Outlined.KeyguardPwnedPassword
+ get() = DataArray
+
+val Icons.Outlined.KeyguardReusedPassword
+ get() = Recycling
+
+val Icons.Outlined.KeyguardPwnedWebsites
+ get() = KeyguardWebsite
+
+val Icons.Outlined.KeyguardUnsecureWebsites
+ get() = KeyguardWebsite
+
+val Icons.Outlined.KeyguardDuplicateWebsites
+ get() = KeyguardWebsite
+
+val Icons.Outlined.KeyguardDuplicateItems
+ get() = CopyAll
+
+val Icons.Outlined.KeyguardIncompleteItems
+ get() = ShortText
+
+val Icons.Outlined.KeyguardExpiringItems
+ get() = Timer
+
+val Icons.Outlined.KeyguardTrashedItems
+ get() = Delete
+
+val Icons.Outlined.KeyguardFailedItems
+ get() = ErrorOutline
+
+val Icons.Outlined.KeyguardPendingSyncItems
+ get() = CloudOff
+
+val Icons.Outlined.KeyguardAuthReprompt
+ get() = Lock
+
+val Icons.Outlined.KeyguardIgnoredAlerts
+ get() = NotificationsOff
+
val Icons.Stub: ImageVector
get() {
if (_stub != null) {
diff --git a/common/src/commonMain/resources/MR/base/strings.xml b/common/src/commonMain/resources/MR/base/strings.xml
index 662ce70a..83f38f6e 100644
--- a/common/src/commonMain/resources/MR/base/strings.xml
+++ b/common/src/commonMain/resources/MR/base/strings.xml
@@ -192,6 +192,9 @@
Master password hint
Fingerprint phrase
What is a fingerprint?
+ Auth re-prompt
+ Pending
+ Failed
Bitwarden premium
Unofficial Bitwarden server
@@ -671,6 +674,14 @@
Fair passwords
Weak passwords
+ Custom filters
+ Search filters
+ You can quickly apply filters by adding a shortcut on your home screen.
+ Delete the filter?
+ Delete the filters?
+ Edit a custom filter
+ Add a custom filter
+
Filter
No filters available
diff --git a/common/src/commonMain/sqldelight/com/artemchep/keyguard/data/CipherFilter.sq b/common/src/commonMain/sqldelight/com/artemchep/keyguard/data/CipherFilter.sq
new file mode 100644
index 00000000..203a75e1
--- /dev/null
+++ b/common/src/commonMain/sqldelight/com/artemchep/keyguard/data/CipherFilter.sq
@@ -0,0 +1,97 @@
+import kotlinx.datetime.Instant;
+
+CREATE TABLE cipherFilter (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ data TEXT NOT NULL,
+ updatedAt INTEGER AS Instant NOT NULL,
+ createdAt INTEGER AS Instant NOT NULL,
+ type TEXT,
+ icon TEXT
+);
+
+INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
+VALUES (
+ '',
+ '',
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ 'login',
+ NULL
+);
+
+INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
+VALUES (
+ '',
+ '',
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ 'card',
+ NULL
+);
+
+INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
+VALUES (
+ '',
+ '',
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ 'identity',
+ NULL
+);
+
+INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
+VALUES (
+ '',
+ '',
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ 'note',
+ NULL
+);
+
+INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
+VALUES (
+ '',
+ '',
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ 'otp',
+ NULL
+);
+
+update {
+ UPDATE cipherFilter
+ SET
+ name = :name,
+ data = :data,
+ updatedAt = :updatedAt,
+ createdAt = :createdAt
+ WHERE
+ id = :id;
+}
+
+rename {
+ UPDATE cipherFilter
+ SET
+ name = :name
+ WHERE
+ id = :id;
+}
+
+insert {
+ INSERT OR IGNORE INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
+ VALUES (:name, :data, :updatedAt, :createdAt, NULL, :icon);
+}
+
+get:
+SELECT *
+FROM cipherFilter
+ORDER BY createdAt DESC;
+
+deleteAll:
+DELETE FROM cipherFilter;
+
+deleteByIds:
+DELETE FROM cipherFilter
+WHERE id IN (:ids);
diff --git a/common/src/commonMain/sqldelight/migrations/10.sqm b/common/src/commonMain/sqldelight/migrations/10.sqm
new file mode 100644
index 00000000..f1708faa
--- /dev/null
+++ b/common/src/commonMain/sqldelight/migrations/10.sqm
@@ -0,0 +1,59 @@
+CREATE TABLE cipherFilter (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ data TEXT NOT NULL,
+ updatedAt INTEGER NOT NULL,
+ createdAt INTEGER NOT NULL,
+ type TEXT,
+ icon TEXT
+);
+
+INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
+VALUES (
+ '',
+ '',
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ 'login',
+ NULL
+);
+
+INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
+VALUES (
+ '',
+ '',
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ 'card',
+ NULL
+);
+
+INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
+VALUES (
+ '',
+ '',
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ 'identity',
+ NULL
+);
+
+INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
+VALUES (
+ '',
+ '',
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ 'note',
+ NULL
+);
+
+INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
+VALUES (
+ '',
+ '',
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ CAST(unixepoch('subsecond') * 1000 AS INTEGER),
+ 'otp',
+ NULL
+);
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 3b36a223..afdbb126 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -28,6 +28,7 @@ androidxBrowser = "1.7.0"
androidxCamera = "1.4.0-alpha04"
androidxCoreKtx = "1.12.0"
androidxCoreSplash = "1.1.0-alpha02"
+androidxCoreShortcuts = "1.0.0"
androidxCredentials = "1.2.0"
androidxDatastore = "1.0.0"
androidxLifecycle = "2.7.0"
@@ -160,6 +161,7 @@ androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", versi
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidxCamera" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCoreKtx" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidxCoreSplash" }
+androidx-core-shortcuts = { module = "androidx.core:core-google-shortcuts", version.ref = "androidxCoreShortcuts" }
androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "androidxCredentials" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidxDatastore" }
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxTestEspresso" }