feat: Custom filters

This commit is contained in:
Artem Chepurnoy 2024-03-06 10:31:57 +02:00
parent f5c3daebcc
commit 4ecd4f0b91
No known key found for this signature in database
GPG Key ID: FAC37D0CF674043E
77 changed files with 2932 additions and 328 deletions

View File

@ -1,11 +1,16 @@
package com.artemchep.keyguard package com.artemchep.keyguard
import android.content.Context import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat 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.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.artemchep.bindin.bindBlock import com.artemchep.bindin.bindBlock
import com.artemchep.keyguard.android.BaseApp 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.journal.DownloadRepository
import com.artemchep.keyguard.android.downloader.worker.AttachmentDownloadAllWorker import com.artemchep.keyguard.android.downloader.worker.AttachmentDownloadAllWorker
import com.artemchep.keyguard.android.passkeysModule 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.model.MasterSession
import com.artemchep.keyguard.common.service.vault.KeyReadWriteRepository import com.artemchep.keyguard.common.service.vault.KeyReadWriteRepository
import com.artemchep.keyguard.common.model.PersistedSession 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.favicon.Favicon
import com.artemchep.keyguard.feature.localization.textResource import com.artemchep.keyguard.feature.localization.textResource
import com.artemchep.keyguard.platform.LeContext import com.artemchep.keyguard.platform.LeContext
@ -223,5 +229,53 @@ class Main : BaseApp(), DIAware {
} }
.launchIn(this) .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)
}
} }
} }

View File

@ -127,6 +127,7 @@ kotlin {
api(libs.androidx.browser) api(libs.androidx.browser)
api(libs.androidx.core.ktx) api(libs.androidx.core.ktx)
api(libs.androidx.core.splashscreen) api(libs.androidx.core.splashscreen)
api(libs.androidx.core.shortcuts)
api(libs.androidx.credentials) api(libs.androidx.credentials)
api(libs.androidx.datastore) api(libs.androidx.datastore)
api(libs.androidx.lifecycle.livedata.ktx) api(libs.androidx.lifecycle.livedata.ktx)

View File

@ -5,6 +5,6 @@
android:viewportWidth="24" android:viewportWidth="24"
android:viewportHeight="24"> android:viewportHeight="24">
<path <path
android:fillColor="#000" android:fillColor="@color/launcher_body_3"
android:pathData="M12,17C10.89,17 10,16.1 10,15C10,13.89 10.89,13 12,13A2,2 0 0,1 14,15A2,2 0 0,1 12,17M18,20V10H6V20H18M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V10C4,8.89 4.89,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z" /> android:pathData="M12,17C10.89,17 10,16.1 10,15C10,13.89 10.89,13 12,13A2,2 0 0,1 14,15A2,2 0 0,1 12,17M18,20V10H6V20H18M18,8A2,2 0 0,1 20,10V20A2,2 0 0,1 18,22H6C4.89,22 4,21.1 4,20V10C4,8.89 4.89,8 6,8H7V6A5,5 0 0,1 12,1A5,5 0 0,1 17,6V8H18M12,3A3,3 0 0,0 9,6V8H15V6A3,3 0 0,0 12,3Z" />
</vector> </vector>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<solid android:color="@color/launcher_handle" />
</shape>
</item>
<item android:gravity="center">
<inset
android:drawable="@drawable/ic_lock_outline"
android:inset="4dp" />
</item>
</layer-list>

View File

@ -1,20 +1,2 @@
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android"> <shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:enabled="true"
android:icon="@drawable/ic_shortcut_2fa"
android:shortcutDisabledMessage="@string/one_time_password"
android:shortcutId="compose"
android:shortcutLongLabel="@string/one_time_password"
android:shortcutShortLabel="@string/one_time_password">
<intent
android:action="android.intent.action.VIEW"
android:targetClass="com.artemchep.keyguard.android.MainActivity"
android:targetPackage="com.artemchep.keyguard">
<extra
android:name="customFilter"
android:value="2fa" />
</intent>
<capability-binding android:key="actions.intent.OPEN_APP_FEATURE" />
</shortcut>
</shortcuts> </shortcuts>

View File

@ -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<String, Set<DFilter.Primitive>>,
val updatedDate: Instant,
val createdDate: Instant,
) : Comparable<DCipherFilter> {
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)
}
}

View File

@ -1,5 +1,9 @@
package com.artemchep.keyguard.common.model 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.Either
import arrow.core.getOrElse import arrow.core.getOrElse
import arrow.core.partially1 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.common.usecase.CipherUrlDuplicateCheck
import com.artemchep.keyguard.core.store.bitwarden.exists import com.artemchep.keyguard.core.store.bitwarden.exists
import com.artemchep.keyguard.feature.crashlytics.crashlyticsTap 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.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 io.ktor.http.Url
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.kodein.di.DirectDI import org.kodein.di.DirectDI
import org.kodein.di.instance import org.kodein.di.instance
import kotlin.collections.Collection import kotlin.collections.Collection
@ -89,7 +111,24 @@ sealed interface DFilter {
_findOne(f, target, predicate) _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 <reified T> findAny(
filter: DFilter,
noinline predicate: (T) -> Boolean = { true },
): T? = findAny(
filter = filter,
target = T::class.java,
predicate = predicate,
)
fun <T> findAny(
filter: DFilter,
target: Class<T>,
predicate: (T) -> Boolean = { true },
): T? = _findAny(
filter = filter,
target = target,
predicate = predicate,
)
private fun <T> _findAny(
filter: DFilter,
target: Class<T>,
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( suspend fun prepare(
@ -145,6 +230,21 @@ sealed interface DFilter {
val key: String 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 @Serializable
@SerialName("or") @SerialName("or")
data class Or<out T : DFilter>( data class Or<out T : DFilter>(
@ -258,7 +358,8 @@ sealed interface DFilter {
data class ById( data class ById(
val id: String?, val id: String?,
val what: What, val what: What,
) : Primitive { ) : PrimitiveSpecial {
@Transient
override val key: String = "$id|$what" override val key: String = "$id|$what"
@Serializable @Serializable
@ -328,10 +429,19 @@ sealed interface DFilter {
@Serializable @Serializable
@SerialName("by_type") @SerialName("by_type")
data class ByType( data class ByType(
@SerialName("cipherType")
val type: DSecret.Type, val type: DSecret.Type,
) : Primitive { ) : PrimitiveSimple {
@Transient
override val key: String = "$type" override val key: String = "$type"
@Transient
override val content = PrimitiveSimple.Content(
title = type.titleH()
.let(TextHolder::Res),
icon = type.iconImageVector(),
)
override suspend fun prepare( override suspend fun prepare(
directDI: DirectDI, directDI: DirectDI,
ciphers: List<DSecret>, ciphers: List<DSecret>,
@ -344,9 +454,17 @@ sealed interface DFilter {
@Serializable @Serializable
@SerialName("by_otp") @SerialName("by_otp")
data object ByOtp : Primitive { data object ByOtp : PrimitiveSimple {
@Transient
override val key: String = "otp" 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( override suspend fun prepare(
directDI: DirectDI, directDI: DirectDI,
ciphers: List<DSecret>, ciphers: List<DSecret>,
@ -359,9 +477,17 @@ sealed interface DFilter {
@Serializable @Serializable
@SerialName("by_attachments") @SerialName("by_attachments")
data object ByAttachments : Primitive { data object ByAttachments : PrimitiveSimple {
@Transient
override val key: String = "attachments" 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( override suspend fun prepare(
directDI: DirectDI, directDI: DirectDI,
ciphers: List<DSecret>, ciphers: List<DSecret>,
@ -374,9 +500,17 @@ sealed interface DFilter {
@Serializable @Serializable
@SerialName("by_passkeys") @SerialName("by_passkeys")
data object ByPasskeys : Primitive { data object ByPasskeys : PrimitiveSimple {
@Transient
override val key: String = "passkeys" 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( override suspend fun prepare(
directDI: DirectDI, directDI: DirectDI,
ciphers: List<DSecret>, ciphers: List<DSecret>,
@ -391,9 +525,17 @@ sealed interface DFilter {
@SerialName("by_pwd_value") @SerialName("by_pwd_value")
data class ByPasswordValue( data class ByPasswordValue(
val value: String?, val value: String?,
) : Primitive { ) : PrimitiveSimple {
@Transient
override val key: String = "pwd_value|$value" 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( override suspend fun prepare(
directDI: DirectDI, directDI: DirectDI,
ciphers: List<DSecret>, ciphers: List<DSecret>,
@ -408,8 +550,22 @@ sealed interface DFilter {
@SerialName("by_pwd_strength") @SerialName("by_pwd_strength")
data class ByPasswordStrength( data class ByPasswordStrength(
val score: PasswordStrength.Score, val score: PasswordStrength.Score,
) : Primitive { ) : PrimitiveSimple {
override val key: String = "$score" @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( override suspend fun prepare(
directDI: DirectDI, directDI: DirectDI,
@ -423,9 +579,17 @@ sealed interface DFilter {
@Serializable @Serializable
@SerialName("by_pwd_duplicates") @SerialName("by_pwd_duplicates")
data object ByPasswordDuplicates : Primitive { data object ByPasswordDuplicates : PrimitiveSimple {
@Transient
override val key: String = "pwd_duplicates" 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( private data class DuplicatesState(
var duplicate: Int, var duplicate: Int,
var ignored: Int, var ignored: Int,
@ -497,9 +661,17 @@ sealed interface DFilter {
@Serializable @Serializable
@SerialName("by_pwd_pwned") @SerialName("by_pwd_pwned")
data object ByPasswordPwned : Primitive { data object ByPasswordPwned : PrimitiveSimple {
@Transient
override val key: String = "pwd_pwned" 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( override suspend fun prepare(
directDI: DirectDI, directDI: DirectDI,
ciphers: List<DSecret>, ciphers: List<DSecret>,
@ -565,9 +737,17 @@ sealed interface DFilter {
@Serializable @Serializable
@SerialName("by_website_pwned") @SerialName("by_website_pwned")
data object ByWebsitePwned : Primitive { data object ByWebsitePwned : PrimitiveSimple {
@Transient
override val key: String = "website_pwned" 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( override suspend fun prepare(
directDI: DirectDI, directDI: DirectDI,
ciphers: List<DSecret>, ciphers: List<DSecret>,
@ -627,9 +807,17 @@ sealed interface DFilter {
@Serializable @Serializable
@SerialName("by_incomplete") @SerialName("by_incomplete")
data object ByIncomplete : Primitive { data object ByIncomplete : PrimitiveSimple {
@Transient
override val key: String = "incomplete" 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( override suspend fun prepare(
directDI: DirectDI, directDI: DirectDI,
ciphers: List<DSecret>, ciphers: List<DSecret>,
@ -676,9 +864,17 @@ sealed interface DFilter {
@Serializable @Serializable
@SerialName("by_expiring") @SerialName("by_expiring")
data object ByExpiring : Primitive { data object ByExpiring : PrimitiveSimple {
@Transient
override val key: String = "expiring" 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( override suspend fun prepare(
directDI: DirectDI, directDI: DirectDI,
ciphers: List<DSecret>, ciphers: List<DSecret>,
@ -726,9 +922,17 @@ sealed interface DFilter {
@Serializable @Serializable
@SerialName("by_unsecure_websites") @SerialName("by_unsecure_websites")
data object ByUnsecureWebsites : Primitive { data object ByUnsecureWebsites : PrimitiveSimple {
@Transient
override val key: String = "unsecure_websites" 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( override suspend fun prepare(
directDI: DirectDI, directDI: DirectDI,
ciphers: List<DSecret>, ciphers: List<DSecret>,
@ -777,9 +981,17 @@ sealed interface DFilter {
@Serializable @Serializable
@SerialName("by_tfa_websites") @SerialName("by_tfa_websites")
data object ByTfaWebsites : Primitive { data object ByTfaWebsites : PrimitiveSimple {
@Transient
override val key: String = "tfa_websites" 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( override suspend fun prepare(
directDI: DirectDI, directDI: DirectDI,
ciphers: List<DSecret>, ciphers: List<DSecret>,
@ -874,9 +1086,17 @@ sealed interface DFilter {
@Serializable @Serializable
@SerialName("by_passkey_websites") @SerialName("by_passkey_websites")
data object ByPasskeyWebsites : Primitive { data object ByPasskeyWebsites : PrimitiveSimple {
@Transient
override val key: String = "passkey_websites" 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( override suspend fun prepare(
directDI: DirectDI, directDI: DirectDI,
ciphers: List<DSecret>, ciphers: List<DSecret>,
@ -983,9 +1203,17 @@ sealed interface DFilter {
@Serializable @Serializable
@SerialName("by_duplicate_websites") @SerialName("by_duplicate_websites")
data object ByDuplicateWebsites : Primitive { data object ByDuplicateWebsites : PrimitiveSimple {
@Transient
override val key: String = "duplicate_websites" 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( override suspend fun prepare(
directDI: DirectDI, directDI: DirectDI,
ciphers: List<DSecret>, ciphers: List<DSecret>,
@ -1049,9 +1277,17 @@ sealed interface DFilter {
@SerialName("by_sync") @SerialName("by_sync")
data class BySync( data class BySync(
val synced: Boolean, val synced: Boolean,
) : Primitive { ) : PrimitiveSimple {
@Transient
override val key: String = "$synced" 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( override suspend fun prepare(
directDI: DirectDI, directDI: DirectDI,
ciphers: List<DSecret>, ciphers: List<DSecret>,
@ -1075,9 +1311,17 @@ sealed interface DFilter {
@SerialName("by_repromt") @SerialName("by_repromt")
data class ByReprompt( data class ByReprompt(
val reprompt: Boolean, val reprompt: Boolean,
) : Primitive { ) : PrimitiveSimple {
@Transient
override val key: String = "$reprompt" 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( override suspend fun prepare(
directDI: DirectDI, directDI: DirectDI,
ciphers: List<DSecret>, ciphers: List<DSecret>,
@ -1092,9 +1336,17 @@ sealed interface DFilter {
@SerialName("by_error") @SerialName("by_error")
data class ByError( data class ByError(
val error: Boolean, val error: Boolean,
) : Primitive { ) : PrimitiveSimple {
@Transient
override val key: String = "$error" 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( override suspend fun prepare(
directDI: DirectDI, directDI: DirectDI,
ciphers: List<DSecret>, ciphers: List<DSecret>,
@ -1116,9 +1368,17 @@ sealed interface DFilter {
@Serializable @Serializable
@SerialName("by_ignored_alerts") @SerialName("by_ignored_alerts")
data object ByIgnoredAlerts : Primitive { data object ByIgnoredAlerts : PrimitiveSimple {
@Transient
override val key: String = "ignored_alerts" 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( override suspend fun prepare(
directDI: DirectDI, directDI: DirectDI,
ciphers: List<DSecret>, ciphers: List<DSecret>,

View File

@ -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<Unit>

View File

@ -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<List<DCipherFilter>>

View File

@ -0,0 +1,7 @@
package com.artemchep.keyguard.common.service.filter
import com.artemchep.keyguard.common.io.IO
interface RemoveCipherFilterById : (
Set<Long>,
) -> IO<Unit>

View File

@ -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<Unit>

View File

@ -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<String, Set<DFilter.Primitive>>,
)

View File

@ -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<Unit> = cipherFilterRepository
.post(data = request)
}

View File

@ -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<List<DCipherFilter>> = cipherFilterRepository.get()
}

View File

@ -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<Long>,
): IO<Unit> = cipherFilterRepository
.removeByIds(ids)
}

View File

@ -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()
}
}

View File

@ -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<String, Set<DFilter.Primitive>>,
)

View File

@ -0,0 +1,6 @@
package com.artemchep.keyguard.common.service.filter.model
data class RenameCipherFilterRequest(
val id: Long,
val name: String,
)

View File

@ -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<List<DCipherFilter>>
fun post(
data: AddCipherFilterRequest,
): IO<Unit>
fun patch(
id: Long,
name: String,
): IO<Unit>
fun removeAll(): IO<Unit>
fun removeByIds(
ids: Set<Long>,
): IO<Unit>
}

View File

@ -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<String, Set<DFilter.Primitive>>,
) : 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<List<DCipherFilter>> =
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<FilterEntity>(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<Unit> = 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<Unit> = daoEffect { dao ->
dao.rename(
id = id,
name = name,
)
}
override fun removeAll(): IO<Unit> =
daoEffect { dao ->
dao.deleteAll()
}
override fun removeByIds(ids: Set<Long>): IO<Unit> =
daoEffect { dao ->
dao.transaction {
ids.forEach { id ->
dao.deleteByIds(id)
}
}
}
private inline fun <T> daoEffect(
crossinline block: suspend (CipherFilterQueries) -> T,
): IO<T> = databaseManager
.get()
.effectMap(dispatcher) { db ->
val dao = db.cipherFilterQueries
block(dao)
}
}

View File

@ -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'

View File

@ -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.GeneratorHistoryRepository
import com.artemchep.keyguard.android.downloader.journal.GeneratorHistoryRepositoryImpl import com.artemchep.keyguard.android.downloader.journal.GeneratorHistoryRepositoryImpl
import com.artemchep.keyguard.common.model.MasterKey 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.GeneratorWordlistRepository
import com.artemchep.keyguard.common.service.wordlist.repo.impl.GeneratorWordlistRepositoryImpl import com.artemchep.keyguard.common.service.wordlist.repo.impl.GeneratorWordlistRepositoryImpl
import com.artemchep.keyguard.common.service.wordlist.repo.GeneratorWordlistWordRepository import com.artemchep.keyguard.common.service.wordlist.repo.GeneratorWordlistWordRepository
@ -483,6 +493,21 @@ fun DI.Builder.createSubDi2(
bindSingleton<GeneratorWordlistWordRepository> { bindSingleton<GeneratorWordlistWordRepository> {
GeneratorWordlistWordRepositoryImpl(this) GeneratorWordlistWordRepositoryImpl(this)
} }
bindSingleton<CipherFilterRepository> {
CipherFilterRepositoryImpl(this)
}
bindSingleton<AddCipherFilter> {
AddCipherFilterImpl(this)
}
bindSingleton<GetCipherFilters> {
GetCipherFiltersImpl(this)
}
bindSingleton<RemoveCipherFilterById> {
RemoveCipherFilterByIdImpl(this)
}
bindSingleton<RenameCipherFilter> {
RenameCipherFilterImpl(this)
}
bindSingleton<UrlOverrideRepository> { bindSingleton<UrlOverrideRepository> {
UrlOverrideRepositoryImpl(this) UrlOverrideRepositoryImpl(this)
} }

View File

@ -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.BitwardenProfile
import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend
import com.artemchep.keyguard.core.store.bitwarden.BitwardenToken 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.CipherUsageHistory
import com.artemchep.keyguard.data.Database import com.artemchep.keyguard.data.Database
import com.artemchep.keyguard.data.GeneratorWordlist import com.artemchep.keyguard.data.GeneratorWordlist
@ -106,6 +107,10 @@ class DatabaseManagerImpl(
Database( Database(
driver = driver, driver = driver,
cipherUsageHistoryAdapter = CipherUsageHistory.Adapter(InstantToLongAdapter), cipherUsageHistoryAdapter = CipherUsageHistory.Adapter(InstantToLongAdapter),
cipherFilterAdapter = CipherFilter.Adapter(
updatedAtAdapter = InstantToLongAdapter,
createdAtAdapter = InstantToLongAdapter,
),
generatorHistoryAdapter = GeneratorHistory.Adapter(InstantToLongAdapter), generatorHistoryAdapter = GeneratorHistory.Adapter(InstantToLongAdapter),
generatorWordlistAdapter = GeneratorWordlist.Adapter(InstantToLongAdapter), generatorWordlistAdapter = GeneratorWordlist.Adapter(InstantToLongAdapter),
generatorEmailRelayAdapter = GeneratorEmailRelay.Adapter(InstantToLongAdapter), generatorEmailRelayAdapter = GeneratorEmailRelay.Adapter(InstantToLongAdapter),

View File

@ -104,6 +104,7 @@ fun AttachmentsScreen(
modifier = modifier, modifier = modifier,
items = state.getOrNull()?.filter?.items.orEmpty(), items = state.getOrNull()?.filter?.items.orEmpty(),
onClear = state.getOrNull()?.filter?.onClear, onClear = state.getOrNull()?.filter?.onClear,
onSave = state.getOrNull()?.filter?.onSave,
) )
}, },
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,

View File

@ -14,6 +14,7 @@ data class AttachmentsState(
data class Filter( data class Filter(
val items: List<FilterItem> = emptyList(), val items: List<FilterItem> = emptyList(),
val onClear: (() -> Unit)? = null, val onClear: (() -> Unit)? = null,
val onSave: (() -> Unit)? = null,
) )
data class Stats( data class Stats(

View File

@ -136,7 +136,7 @@ fun produceAttachmentsScreenState(
) { ) {
val selectionHandle = selectionHandle("selection") val selectionHandle = selectionHandle("selection")
val filterResult = createFilter() val filterResult = createFilter(directDI)
val ciphersFlow = getCiphers() val ciphersFlow = getCiphers()
@ -481,6 +481,7 @@ fun produceAttachmentsScreenState(
filter = AttachmentsState.Filter( filter = AttachmentsState.Filter(
items = filterState.items, items = filterState.items,
onClear = filterState.onClear, onClear = filterState.onClear,
onSave = filterState.onSave,
), ),
stats = AttachmentsState.Stats( stats = AttachmentsState.Stats(
totalAttachments = 0, totalAttachments = 0,

View File

@ -127,6 +127,7 @@ fun ExportScreenSkeleton(
modifier = modifier, modifier = modifier,
items = items, items = items,
onClear = null, onClear = null,
onSave = null,
) )
}, },
) { modifier, tabletUi -> ) { modifier, tabletUi ->
@ -185,6 +186,7 @@ fun ExportScreenOk(
modifier = modifier, modifier = modifier,
items = filter.items, items = filter.items,
onClear = filter.onClear, onClear = filter.onClear,
onSave = filter.onSave,
) )
}, },
) { modifier, tabletUi -> ) { modifier, tabletUi ->
@ -211,14 +213,14 @@ private fun ExportScreenFilterList(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
items: List<FilterItem>, items: List<FilterItem>,
onClear: (() -> Unit)?, onClear: (() -> Unit)?,
onSave: (() -> Unit)?,
) { ) {
FilterScreen( FilterScreen(
modifier = modifier, modifier = modifier,
count = null, count = null,
items = items, items = items,
onClear = onClear, onClear = onClear,
actions = { onSave = onSave,
},
) )
} }
@ -227,12 +229,14 @@ private fun ExportScreenFilterButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
items: List<FilterItem>, items: List<FilterItem>,
onClear: (() -> Unit)?, onClear: (() -> Unit)?,
onSave: (() -> Unit)?,
) { ) {
FilterButton( FilterButton(
modifier = modifier, modifier = modifier,
count = null, count = null,
items = items, items = items,
onClear = onClear, onClear = onClear,
onSave = onSave,
) )
} }
@ -277,6 +281,7 @@ private fun ExportScreen(
modifier = Modifier, modifier = Modifier,
items = filter.items, items = filter.items,
onClear = filter.onClear, onClear = filter.onClear,
onSave = filter.onSave,
) )
} }
}, },

View File

@ -26,6 +26,7 @@ data class ExportState(
data class Filter( data class Filter(
val items: List<FilterItem>, val items: List<FilterItem>,
val onClear: (() -> Unit)? = null, val onClear: (() -> Unit)? = null,
val onSave: (() -> Unit)? = null,
) )
@Immutable @Immutable

View File

@ -131,7 +131,7 @@ fun produceExportScreenState(
} }
.shareIn(screenScope, SharingStarted.WhileSubscribed(), replay = 1) .shareIn(screenScope, SharingStarted.WhileSubscribed(), replay = 1)
val filterResult = createFilter() val filterResult = createFilter(directDI)
val filteredCiphersFlow = ciphersFlow val filteredCiphersFlow = ciphersFlow
.map { .map {
@ -192,6 +192,7 @@ fun produceExportScreenState(
ExportState.Filter( ExportState.Filter(
items = filterState.items, items = filterState.items,
onClear = filterState.onClear, onClear = filterState.onClear,
onSave = filterState.onSave,
) )
} }
.stateIn(screenScope) .stateIn(screenScope)

View File

@ -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()
}
}

View File

@ -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,
)
}
}

View File

@ -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()
}
}

View File

@ -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<CipherFiltersListState>,
) {
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,
)
}

View File

@ -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<Filter>,
val selection: StateFlow<Selection?>,
val content: Loadable<Either<Throwable, Content>>,
) {
@Immutable
data class Filter(
val revision: Int,
val query: TextFieldModel2,
) {
companion object
}
@Immutable
data class Content(
val revision: Int,
val items: List<Item>,
) {
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<SelectableItemState>,
val onClick: () -> Unit,
)
}

View File

@ -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<CipherFiltersListState> = 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<DCipherFilter>.toItems(): List<CipherFiltersListState.Item> {
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)
}
}

View File

@ -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<DCipherFilter>,
) {
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)
}
}

View File

@ -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() {
}
}

View File

@ -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,
)
}
}

View File

@ -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,
)
}
}

View File

@ -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<Toolbar>,
val filterFlow: Flow<Filter>,
val content: Either<Throwable, Content>,
val onClose: (() -> Unit)? = null,
) {
@Immutable
data class Content(
val model: DCipherFilter?,
) {
companion object
}
@Immutable
data class Toolbar(
val model: DCipherFilter?,
val actions: ImmutableList<ContextItem>,
)
@Immutable
data class Filter(
val items: ImmutableList<FilterItem> = persistentListOf(),
)
}

View File

@ -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<CipherFilterViewState> = 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<Flow<FilterItem>>()
val l = mutableListOf<Flow<FilterItem>>()
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)
}
}

View File

@ -24,6 +24,7 @@ import com.artemchep.keyguard.feature.confirmation.ConfirmationRoute
import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogIntent import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogIntent
import com.artemchep.keyguard.feature.crashlytics.crashlyticsAttempt import com.artemchep.keyguard.feature.crashlytics.crashlyticsAttempt
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon 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.NavigationIntent
import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver
import com.artemchep.keyguard.feature.navigation.state.produceScreenState import com.artemchep.keyguard.feature.navigation.state.produceScreenState
@ -292,18 +293,7 @@ fun produceEmailRelayListState(
) )
} }
} }
val icon = VaultItemIcon.TextIcon( val icon = VaultItemIcon.TextIcon.short(it.name)
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 selectableFlow = selectionHandle val selectableFlow = selectionHandle
.idsFlow .idsFlow

View File

@ -293,9 +293,6 @@ private fun WordlistItem(
val nextEntry = navigationNextEntryOrNull() val nextEntry = navigationNextEntryOrNull()
val nextRoute = nextEntry?.route as? WordlistViewRoute val nextRoute = nextEntry?.route as? WordlistViewRoute
MaterialTheme.colorScheme.selectedContainer
.takeIf { LocalHasDetailPane.current }
?: Color.Unspecified
val selected = nextRoute?.args?.wordlistId == item.wordlistId val selected = nextRoute?.args?.wordlistId == item.wordlistId
if (selected) { if (selected) {
return@run MaterialTheme.colorScheme.selectedContainer return@run MaterialTheme.colorScheme.selectedContainer

View File

@ -1,7 +1,6 @@
package com.artemchep.keyguard.feature.generator.wordlist.list package com.artemchep.keyguard.feature.generator.wordlist.list
import androidx.compose.material.icons.Icons 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.AttachFile
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit 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.util.WordlistUtil
import com.artemchep.keyguard.feature.generator.wordlist.view.WordlistViewRoute 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.VaultItemIcon
import com.artemchep.keyguard.feature.home.vault.model.short
import com.artemchep.keyguard.feature.navigation.NavigationIntent import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.state.produceScreenState import com.artemchep.keyguard.feature.navigation.state.produceScreenState
import com.artemchep.keyguard.res.Res import com.artemchep.keyguard.res.Res
@ -128,7 +128,7 @@ fun produceWordlistListState(
this += FlatItemAction( this += FlatItemAction(
icon = Icons.Outlined.Edit, icon = Icons.Outlined.Edit,
title = translate(Res.strings.edit), title = translate(Res.strings.edit),
onClick = WordlistUtil::onEdit onClick = WordlistUtil::onRename
.partially1(this@produceScreenState) .partially1(this@produceScreenState)
.partially1(editWordlist) .partially1(editWordlist)
.partially1(selectedItem), .partially1(selectedItem),
@ -166,18 +166,7 @@ fun produceWordlistListState(
.map { list -> .map { list ->
list list
.map { .map {
val icon = VaultItemIcon.TextIcon( val icon = VaultItemIcon.TextIcon.short(it.name)
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 selectableFlow = selectionHandle val selectableFlow = selectionHandle
.idsFlow .idsFlow

View File

@ -18,12 +18,13 @@ import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver
import com.artemchep.keyguard.feature.navigation.state.RememberStateFlowScope import com.artemchep.keyguard.feature.navigation.state.RememberStateFlowScope
import com.artemchep.keyguard.res.Res 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.KeyguardWordlist
import com.artemchep.keyguard.ui.icons.icon import com.artemchep.keyguard.ui.icons.icon
object WordlistUtil { object WordlistUtil {
context(RememberStateFlowScope) context(RememberStateFlowScope)
fun onEdit( fun onRename(
editWordlist: EditWordlist, editWordlist: EditWordlist,
entity: DGeneratorWordlist, entity: DGeneratorWordlist,
) { ) {
@ -43,7 +44,7 @@ object WordlistUtil {
route = ConfirmationRoute( route = ConfirmationRoute(
args = ConfirmationRoute.Args( args = ConfirmationRoute.Args(
icon = icon( icon = icon(
main = Icons.Outlined.KeyguardWordlist, main = Icons.Outlined.KeyguardCipherFilter,
secondary = Icons.Outlined.Edit, secondary = Icons.Outlined.Edit,
), ),
title = translate(Res.strings.wordlist_edit_wordlist_title), title = translate(Res.strings.wordlist_edit_wordlist_title),

View File

@ -79,7 +79,7 @@ fun produceWordlistViewState(
this += FlatItemAction( this += FlatItemAction(
icon = Icons.Outlined.Edit, icon = Icons.Outlined.Edit,
title = translate(Res.strings.edit), title = translate(Res.strings.edit),
onClick = WordlistUtil::onEdit onClick = WordlistUtil::onRename
.partially1(this@produceScreenState) .partially1(this@produceScreenState)
.partially1(editWordlist) .partially1(editWordlist)
.partially1(wordlist), .partially1(wordlist),

View File

@ -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.settings.accounts.model.AccountItem
import com.artemchep.keyguard.feature.home.vault.VaultRoute 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.VaultItemIcon
import com.artemchep.keyguard.feature.home.vault.model.short
import com.artemchep.keyguard.feature.navigation.NavigationIntent import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.state.produceScreenState import com.artemchep.keyguard.feature.navigation.state.produceScreenState
import com.artemchep.keyguard.res.Res import com.artemchep.keyguard.res.Res
@ -235,18 +236,7 @@ fun accountListScreenState(
val busy = syncing || removing val busy = syncing || removing
val accent = profile?.accentColor val accent = profile?.accentColor
?: generateAccentColorsByAccountId(it.id.id) ?: generateAccentColorsByAccountId(it.id.id)
val icon = VaultItemIcon.TextIcon( val icon = VaultItemIcon.TextIcon.short(profile?.name.orEmpty())
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(),
)
AccountItem.Item( AccountItem.Item(
id = it.id.id, id = it.id.id,
icon = icon, icon = icon,

View File

@ -778,7 +778,8 @@ fun BoxScope.VaultItemIcon2(
.align(Alignment.Center), .align(Alignment.Center),
imageVector = icon.imageVector, imageVector = icon.imageVector,
contentDescription = null, contentDescription = null,
tint = Color.Black, tint = Color.Black
.combineAlpha(MediumEmphasisAlpha),
) )
} }

View File

@ -14,8 +14,9 @@ sealed interface FilterItem : FilterItemModel {
data class Section( data class Section(
override val sectionId: String, override val sectionId: String,
override val text: String, override val text: String,
override val expandable: Boolean = true,
override val expanded: Boolean = true, override val expanded: Boolean = true,
override val onClick: () -> Unit, override val onClick: (() -> Unit)?,
) : FilterItem, FilterItemModel.Section { ) : FilterItem, FilterItemModel.Section {
companion object; companion object;
@ -25,7 +26,12 @@ sealed interface FilterItem : FilterItemModel {
data class Item( data class Item(
override val sectionId: String, override val sectionId: String,
val filterSectionId: String, val filterSectionId: String,
val filters: Set<DFilter.Primitive>, val filter: Filter,
/**
* Unique identifier of the set of
* filters.
*/
val filterId: String = filter.id,
override val checked: Boolean, override val checked: Boolean,
override val fill: Boolean, override val fill: Boolean,
override val indent: Int = 0, override val indent: Int = 0,
@ -33,10 +39,25 @@ sealed interface FilterItem : FilterItemModel {
override val title: String, override val title: String,
override val text: String?, override val text: String?,
override val onClick: (() -> Unit)?, override val onClick: (() -> Unit)?,
override val enabled: Boolean = onClick != null,
) : FilterItem, FilterItemModel.Item { ) : FilterItem, FilterItemModel.Item {
companion object; companion object;
override val id: String = override val id: String = "$sectionId|$filterSectionId|$filterId"
sectionId + "|" + filterSectionId + "|" + filters.joinToString(separator = ",") { it.key }
sealed interface Filter {
val id: String
data class Toggle(
val filters: Set<DFilter.Primitive>,
override val id: String = filters
.joinToString(separator = ",") { it.key },
) : Filter
data class Apply(
val filters: Map<String, Set<DFilter.Primitive>>,
override val id: String,
) : Filter
}
} }
} }

View File

@ -2,6 +2,8 @@ package com.artemchep.keyguard.feature.home.vault.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.vector.ImageVector 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.AppIconUrl
import com.artemchep.keyguard.feature.favicon.FaviconUrl import com.artemchep.keyguard.feature.favicon.FaviconUrl
import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.ImageResource
@ -33,5 +35,30 @@ sealed interface VaultItemIcon {
@Immutable @Immutable
data class TextIcon( data class TextIcon(
val text: String, 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,
)
} }

View File

@ -6,8 +6,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons 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.CloudOff
import androidx.compose.material.icons.outlined.ErrorOutline 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.FolderOff
import androidx.compose.material.icons.outlined.Key import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.outlined.Lock 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 androidx.compose.ui.unit.dp
import arrow.core.partially1 import arrow.core.partially1
import arrow.core.widen import arrow.core.widen
import com.artemchep.keyguard.common.io.launchIn
import com.artemchep.keyguard.common.model.DAccount import com.artemchep.keyguard.common.model.DAccount
import com.artemchep.keyguard.common.model.DCollection import com.artemchep.keyguard.common.model.DCollection
import com.artemchep.keyguard.common.model.DFilter 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.DSecret
import com.artemchep.keyguard.common.model.iconImageVector import com.artemchep.keyguard.common.model.iconImageVector
import com.artemchep.keyguard.common.model.titleH 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.usecase.GetFolderTree
import com.artemchep.keyguard.common.util.StringComparatorIgnoreCase 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.component.rememberSecretAccentColor
import com.artemchep.keyguard.feature.home.vault.model.FilterItem import com.artemchep.keyguard.feature.home.vault.model.FilterItem
import com.artemchep.keyguard.feature.home.vault.search.filter.FilterHolder 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.PersistedStorage
import com.artemchep.keyguard.feature.navigation.state.RememberStateFlowScope 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.res.Res
import com.artemchep.keyguard.ui.icons.AccentColors import com.artemchep.keyguard.ui.icons.AccentColors
import com.artemchep.keyguard.ui.icons.IconBox import com.artemchep.keyguard.ui.icons.IconBox
import com.artemchep.keyguard.ui.icons.KeyguardAttachment 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.KeyguardTwoFa
import com.artemchep.keyguard.ui.icons.icon
import com.artemchep.keyguard.ui.icons.iconSmall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -66,10 +84,54 @@ private fun <T, R> mapCiphers(
data class CreateFilterResult( data class CreateFilterResult(
val filterFlow: Flow<FilterHolder>, val filterFlow: Flow<FilterHolder>,
val onToggle: (String, Set<DFilter.Primitive>) -> Unit, val onToggle: (String, Set<DFilter.Primitive>) -> Unit,
val onApply: (Map<String, Set<DFilter.Primitive>>) -> Unit,
val onClear: () -> Unit, val onClear: () -> Unit,
val onSave: (Map<String, Set<DFilter.Primitive>>) -> 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( val emptyState = FilterHolder(
state = mapOf(), state = mapOf(),
) )
@ -86,6 +148,25 @@ suspend fun RememberStateFlowScope.createFilter(): CreateFilterResult {
val onClear = { val onClear = {
filterSink.value = emptyState filterSink.value = emptyState
} }
val onSave = { state: Map<String, Set<DFilter.Primitive>> ->
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<DFilter.Primitive> -> val onToggle = { sectionId: String, filters: Set<DFilter.Primitive> ->
filterSink.update { holder -> filterSink.update { holder ->
val activeFilters = holder.state.getOrElse(sectionId) { emptySet() } val activeFilters = holder.state.getOrElse(sectionId) { emptySet() }
@ -105,10 +186,25 @@ suspend fun RememberStateFlowScope.createFilter(): CreateFilterResult {
) )
} }
} }
val onApply = { state: Map<String, Set<DFilter.Primitive>> ->
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( return CreateFilterResult(
filterFlow = filterSink, filterFlow = filterSink,
onToggle = onToggle, onToggle = onToggle,
onApply = onApply,
onClear = onClear, onClear = onClear,
onSave = onSave,
) )
} }
@ -116,6 +212,7 @@ data class OurFilterResult(
val rev: Int = 0, val rev: Int = 0,
val items: List<FilterItem> = emptyList(), val items: List<FilterItem> = emptyList(),
val onClear: (() -> Unit)? = null, val onClear: (() -> Unit)? = null,
val onSave: (() -> Unit)? = null,
) )
data class FilterParams( data class FilterParams(
@ -158,6 +255,8 @@ suspend fun <
input: CreateFilterResult, input: CreateFilterResult,
params: FilterParams = FilterParams(), params: FilterParams = FilterParams(),
): Flow<OurFilterResult> { ): Flow<OurFilterResult> {
val getCipherFilters: GetCipherFilters = directDI.instance()
val storage = kotlin.run { val storage = kotlin.run {
val disk = loadDiskHandle("ciphers.filter") val disk = loadDiskHandle("ciphers.filter")
PersistedStorage.InDisk(disk) PersistedStorage.InDisk(disk)
@ -213,17 +312,12 @@ suspend fun <
sectionId: String, sectionId: String,
sectionTitle: String, sectionTitle: String,
collapse: Boolean = true, collapse: Boolean = true,
checked: (FilterItem.Item, FilterHolder) -> Boolean,
) = this ) = this
.combine(input.filterFlow) { items, filterHolder -> .combine(input.filterFlow) { items, filterHolder ->
items items
.map { item -> .map { item ->
val shouldBeChecked = kotlin.run { val shouldBeChecked = checked(item, filterHolder)
val activeFilters = filterHolder.state[item.filterSectionId].orEmpty()
item.filters
.all { itemFilter ->
itemFilter in activeFilters
}
}
if (shouldBeChecked == item.checked) { if (shouldBeChecked == item.checked) {
return@map item return@map item
} }
@ -233,7 +327,7 @@ suspend fun <
} }
.distinctUntilChanged() .distinctUntilChanged()
.map { items -> .map { items ->
if (items.size <= 1 && collapse) { if (items.size <= 1 && collapse || items.isEmpty()) {
// Do not show a single filter item. // Do not show a single filter item.
return@map emptyList<FilterItem>() return@map emptyList<FilterItem>()
} }
@ -253,15 +347,43 @@ suspend fun <
} }
} }
val setOfNull = setOf(null) fun Flow<List<FilterItem.Item>>.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" is FilterItem.Item.Filter.Apply -> kotlin.run {
val typeSectionId = "type" // If the size of the current state and the item
val accountSectionId = "account" // state is different then there's no way it's currently
val folderSectionId = "folder" // selected.
val collectionSectionId = "collection" if (filterHolder.state.size != item.filter.filters.size) {
val organizationSectionId = "organization" return@run false
val miscSectionId = "misc" }
item.filter.filters
.all { (filterSectionId, filterSet) ->
val activeFilters = filterHolder.state[filterSectionId].orEmpty()
activeFilters == filterSet
}
}
}
},
)
val setOfNull = setOf(null)
fun createFilterAction( fun createFilterAction(
sectionId: String, sectionId: String,
@ -276,7 +398,9 @@ suspend fun <
) = FilterItem.Item( ) = FilterItem.Item(
sectionId = sectionId, sectionId = sectionId,
filterSectionId = filterSectionId, filterSectionId = filterSectionId,
filter = FilterItem.Item.Filter.Toggle(
filters = filter, filters = filter,
),
leading = when { leading = when {
icon != null -> { icon != null -> {
// composable // composable
@ -325,7 +449,7 @@ suspend fun <
tint: AccentColors? = null, tint: AccentColors? = null,
icon: ImageVector? = null, icon: ImageVector? = null,
) = createFilterAction( ) = createFilterAction(
sectionId = accountSectionId, sectionId = FilterSection.ACCOUNT.id,
filter = accountIds filter = accountIds
.asSequence() .asSequence()
.map { accountId -> .map { accountId ->
@ -343,10 +467,10 @@ suspend fun <
fun createTypeFilterAction( fun createTypeFilterAction(
type: DSecret.Type, type: DSecret.Type,
sectionId: String = typeSectionId, sectionId: String = FilterSection.TYPE.id,
) = createFilterAction( ) = createFilterAction(
sectionId = sectionId, sectionId = sectionId,
filterSectionId = typeSectionId, filterSectionId = FilterSection.TYPE.id,
filter = setOf( filter = setOf(
DFilter.ByType(type), DFilter.ByType(type),
), ),
@ -361,7 +485,7 @@ suspend fun <
fill: Boolean, fill: Boolean,
indent: Int, indent: Int,
) = createFilterAction( ) = createFilterAction(
sectionId = folderSectionId, sectionId = FilterSection.FOLDER.id,
filter = folderIds filter = folderIds
.asSequence() .asSequence()
.map { folderId -> .map { folderId ->
@ -382,7 +506,7 @@ suspend fun <
title: String, title: String,
icon: ImageVector? = null, icon: ImageVector? = null,
) = createFilterAction( ) = createFilterAction(
sectionId = collectionSectionId, sectionId = FilterSection.COLLECTION.id,
filter = collectionIds filter = collectionIds
.asSequence() .asSequence()
.map { collectionId -> .map { collectionId ->
@ -401,7 +525,7 @@ suspend fun <
title: String, title: String,
icon: ImageVector? = null, icon: ImageVector? = null,
) = createFilterAction( ) = createFilterAction(
sectionId = organizationSectionId, sectionId = FilterSection.ORGANIZATION.id,
filter = organizationIds filter = organizationIds
.asSequence() .asSequence()
.map { organizationId -> .map { organizationId ->
@ -432,7 +556,8 @@ suspend fun <
.combine(filterAccountsWithCiphers) { items, accountIds -> .combine(filterAccountsWithCiphers) { items, accountIds ->
items items
.filter { filterItem -> .filter { filterItem ->
filterItem.filters val filterItemFilter = filterItem.filter as FilterItem.Item.Filter.Toggle
filterItemFilter.filters
.any { filter -> .any { filter ->
val filterFixed = filter as DFilter.ById val filterFixed = filter as DFilter.ById
require(filterFixed.what == DFilter.ById.What.ACCOUNT) require(filterFixed.what == DFilter.ById.What.ACCOUNT)
@ -441,8 +566,8 @@ suspend fun <
} }
} }
.aaa( .aaa(
sectionId = accountSectionId, sectionId = FilterSection.ACCOUNT.id,
sectionTitle = translate(Res.strings.account), sectionTitle = translate(FilterSection.ACCOUNT.title),
) )
.filterSection(params.section.account) .filterSection(params.section.account)
@ -468,8 +593,8 @@ suspend fun <
} }
} }
.aaa( .aaa(
sectionId = typeSectionId, sectionId = FilterSection.TYPE.id,
sectionTitle = translate(Res.strings.type), sectionTitle = translate(FilterSection.TYPE.title),
) )
.filterSection(params.section.type) .filterSection(params.section.type)
@ -531,7 +656,8 @@ suspend fun <
.combine(filterFoldersWithCiphers) { items, folderIds -> .combine(filterFoldersWithCiphers) { items, folderIds ->
items items
.filter { filterItem -> .filter { filterItem ->
filterItem.filters val filterItemFilter = filterItem.filter as FilterItem.Item.Filter.Toggle
filterItemFilter.filters
.any { filter -> .any { filter ->
val filterFixed = filter as DFilter.ById val filterFixed = filter as DFilter.ById
require(filterFixed.what == DFilter.ById.What.FOLDER) require(filterFixed.what == DFilter.ById.What.FOLDER)
@ -540,8 +666,8 @@ suspend fun <
} }
} }
.aaa( .aaa(
sectionId = folderSectionId, sectionId = FilterSection.FOLDER.id,
sectionTitle = translate(Res.strings.folder), sectionTitle = translate(FilterSection.FOLDER.title),
) )
.filterSection(params.section.folder) .filterSection(params.section.folder)
@ -573,7 +699,8 @@ suspend fun <
.combine(filterCollectionsWithCiphers) { items, collectionIds -> .combine(filterCollectionsWithCiphers) { items, collectionIds ->
items items
.filter { filterItem -> .filter { filterItem ->
filterItem.filters val filterItemFilter = filterItem.filter as FilterItem.Item.Filter.Toggle
filterItemFilter.filters
.any { filter -> .any { filter ->
val filterFixed = filter as DFilter.ById val filterFixed = filter as DFilter.ById
require(filterFixed.what == DFilter.ById.What.COLLECTION) require(filterFixed.what == DFilter.ById.What.COLLECTION)
@ -582,8 +709,8 @@ suspend fun <
} }
} }
.aaa( .aaa(
sectionId = collectionSectionId, sectionId = FilterSection.COLLECTION.id,
sectionTitle = translate(Res.strings.collection), sectionTitle = translate(FilterSection.COLLECTION.title),
) )
.filterSection(params.section.collection) .filterSection(params.section.collection)
@ -615,7 +742,8 @@ suspend fun <
.combine(filterOrganizationsWithCiphers) { items, organizationIds -> .combine(filterOrganizationsWithCiphers) { items, organizationIds ->
items items
.filter { filterItem -> .filter { filterItem ->
filterItem.filters val filterItemFilter = filterItem.filter as FilterItem.Item.Filter.Toggle
filterItemFilter.filters
.any { filter -> .any { filter ->
val filterFixed = filter as DFilter.ById val filterFixed = filter as DFilter.ById
require(filterFixed.what == DFilter.ById.What.ORGANIZATION) require(filterFixed.what == DFilter.ById.What.ORGANIZATION)
@ -624,74 +752,74 @@ suspend fun <
} }
} }
.aaa( .aaa(
sectionId = organizationSectionId, sectionId = FilterSection.ORGANIZATION.id,
sectionTitle = translate(Res.strings.organization), sectionTitle = translate(FilterSection.ORGANIZATION.title),
) )
.filterSection(params.section.organization) .filterSection(params.section.organization)
val filterMiscAll = listOf( val filterMiscAll = listOf(
createFilterAction( createFilterAction(
sectionId = miscSectionId, sectionId = FilterSection.MISC.id,
filter = setOf( filter = setOf(
DFilter.ByOtp, DFilter.ByOtp,
), ),
filterSectionId = "$miscSectionId.otp", filterSectionId = "${FilterSection.MISC.id}.otp",
title = translate(Res.strings.one_time_password), title = translate(DFilter.ByOtp.content.title),
icon = Icons.Outlined.KeyguardTwoFa, icon = DFilter.ByOtp.content.icon,
), ),
createFilterAction( createFilterAction(
sectionId = miscSectionId, sectionId = FilterSection.MISC.id,
filter = setOf( filter = setOf(
DFilter.ByAttachments, DFilter.ByAttachments,
), ),
filterSectionId = "$miscSectionId.attachments", filterSectionId = "${FilterSection.MISC.id}.attachments",
title = translate(Res.strings.attachments), title = translate(DFilter.ByAttachments.content.title),
icon = Icons.Outlined.KeyguardAttachment, icon = DFilter.ByAttachments.content.icon,
), ),
createFilterAction( createFilterAction(
sectionId = miscSectionId, sectionId = FilterSection.MISC.id,
filter = setOf( filter = setOf(
DFilter.ByPasskeys, DFilter.ByPasskeys,
), ),
filterSectionId = "$miscSectionId.passkeys", filterSectionId = "${FilterSection.MISC.id}.passkeys",
title = translate(Res.strings.passkeys), title = translate(DFilter.ByPasskeys.content.title),
icon = Icons.Outlined.Key, icon = DFilter.ByPasskeys.content.icon,
), ),
createFilterAction( createFilterAction(
sectionId = miscSectionId, sectionId = FilterSection.MISC.id,
filter = setOf( filter = setOf(
DFilter.ByReprompt(reprompt = true), DFilter.ByReprompt(reprompt = true),
), ),
filterSectionId = "$miscSectionId.reprompt", filterSectionId = "${FilterSection.MISC.id}.reprompt",
title = "Auth re-prompt", title = translate(Res.strings.filter_auth_reprompt_items),
icon = Icons.Outlined.Lock, icon = Icons.Outlined.KeyguardAuthReprompt,
), ),
createFilterAction( createFilterAction(
sectionId = miscSectionId, sectionId = FilterSection.MISC.id,
filter = setOf( filter = setOf(
DFilter.BySync(synced = false), DFilter.BySync(synced = false),
), ),
filterSectionId = "$miscSectionId.sync", filterSectionId = "${FilterSection.MISC.id}.sync",
title = "Un-synced", title = translate(Res.strings.filter_pending_items),
icon = Icons.Outlined.CloudOff, icon = Icons.Outlined.KeyguardPendingSyncItems,
), ),
createFilterAction( createFilterAction(
sectionId = miscSectionId, sectionId = FilterSection.MISC.id,
filter = setOf( filter = setOf(
DFilter.ByError(error = true), DFilter.ByError(error = true),
), ),
filterSectionId = "$miscSectionId.error", filterSectionId = "${FilterSection.MISC.id}.error",
title = "Failed", title = translate(Res.strings.filter_failed_items),
icon = Icons.Outlined.ErrorOutline, icon = Icons.Outlined.KeyguardFailedItems,
), ),
createFilterAction( createFilterAction(
sectionId = miscSectionId, sectionId = FilterSection.MISC.id,
filter = setOf( filter = setOf(
DFilter.ByIgnoredAlerts, DFilter.ByIgnoredAlerts,
), ),
filterSectionId = "$miscSectionId.watchtower_alerts", filterSectionId = "${FilterSection.MISC.id}.watchtower_alerts",
title = translate(Res.strings.ignored_alerts), title = translate(Res.strings.ignored_alerts),
icon = Icons.Outlined.NotificationsOff, icon = Icons.Outlined.KeyguardIgnoredAlerts,
), ),
) )
@ -700,59 +828,51 @@ suspend fun <
filterMiscAll filterMiscAll
} }
.aaa( .aaa(
sectionId = miscSectionId, sectionId = FilterSection.MISC.id,
sectionTitle = translate(Res.strings.misc), sectionTitle = translate(FilterSection.MISC.title),
collapse = false, collapse = false,
) )
.filterSection(params.section.misc) .filterSection(params.section.misc)
val filterCustomTypesAll = listOf( if (params.deeplinkCustomFilter != null) {
DSecret.Type.Login to createTypeFilterAction( val customFilter = kotlin.run {
sectionId = customSectionId, val customFilters = getCipherFilters()
type = DSecret.Type.Login, .first()
), customFilters.firstOrNull { it.id == params.deeplinkCustomFilter }
DSecret.Type.Card to createTypeFilterAction( }
sectionId = customSectionId, if (customFilter != null) {
type = DSecret.Type.Card, input.onApply(customFilter.filter)
), }
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,
),
)
} }
val filterCustomListFlow = flowOf(Unit) val filterCustomListFlow = getCipherFilters()
.map { .map { filters ->
filterCustomTypesAll.map { it.second } + filterCustomAll 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( .aaa(
sectionId = customSectionId, sectionId = FilterSection.CUSTOM.id,
sectionTitle = translate(Res.strings.custom), sectionTitle = translate(FilterSection.CUSTOM.title),
collapse = false, collapse = false,
) )
.filterSection(params.section.custom) .filterSection(params.section.custom)
@ -795,7 +915,7 @@ suspend fun <
when (it) { when (it) {
is FilterItem.Section -> null is FilterItem.Section -> null
is FilterItem.Item -> it.takeIf { it.checked } is FilterItem.Item -> it.takeIf { it.checked }
?.sectionId ?.filterSectionId
} }
} }
.toSet() .toSet()
@ -808,9 +928,11 @@ suspend fun <
val fastEnabled = item.checked || val fastEnabled = item.checked ||
// If one of the items in a section is enabled, then // If one of the items in a section is enabled, then
// enable the whole section. // enable the whole section.
item.sectionId in checkedSectionIds item.filterSectionId in checkedSectionIds
val enabled = fastEnabled || kotlin.run { val enabled = fastEnabled || kotlin.run {
item.filters val filterItemFilter = item.filter as? FilterItem.Item.Filter.Toggle
?: return@run true
filterItemFilter.filters
.any { filter -> .any { filter ->
val filterPredicate = filter.prepare(directDI, outputCiphers) val filterPredicate = filter.prepare(directDI, outputCiphers)
outputCiphers.any(filterPredicate) outputCiphers.any(filterPredicate)
@ -822,7 +944,10 @@ suspend fun <
return@forEach return@forEach
} }
out += item.copy(onClick = null) out += item.copy(
onClick = null,
enabled = false,
)
} }
} }
} }
@ -833,6 +958,9 @@ suspend fun <
rev = b.id, rev = b.id,
items = a, items = a,
onClear = input.onClear.takeIf { b.id != 0 }, onClear = input.onClear.takeIf { b.id != 0 },
onSave = input.onSave
.takeIf { b.id != 0 }
?.partially1(b.state),
) )
} }
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)

View File

@ -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.component.obscureCardNumber
import com.artemchep.keyguard.feature.home.vault.model.VaultItem2 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.VaultItemIcon
import com.artemchep.keyguard.feature.home.vault.model.short
import com.artemchep.keyguard.feature.navigation.state.TranslatorScope import com.artemchep.keyguard.feature.navigation.state.TranslatorScope
import com.artemchep.keyguard.res.Res import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.FlatItemAction import com.artemchep.keyguard.ui.FlatItemAction
@ -166,9 +167,7 @@ fun DSecret.toVaultItemIcon(
} }
} }
val textIcon = if (name.isNotBlank()) { val textIcon = if (name.isNotBlank()) {
VaultItemIcon.TextIcon( VaultItemIcon.TextIcon.short(name)
text = name.take(2),
)
} else { } else {
null null
} }

View File

@ -223,19 +223,13 @@ private fun VaultListFilterScreen(
val count = (state.content as? VaultListState.Content.Items)?.count val count = (state.content as? VaultListState.Content.Items)?.count
val filters = state.filters val filters = state.filters
val clearFilters = state.clearFilters val clearFilters = state.clearFilters
val saveFilters = state.saveFilters
FilterScreen( FilterScreen(
modifier = modifier, modifier = modifier,
count = count, count = count,
items = filters, items = filters,
onClear = clearFilters, onClear = clearFilters,
actions = { onSave = saveFilters,
VaultListSortButton(
state = state,
)
OptionsButton(
actions = state.actions,
)
},
) )
} }
@ -247,11 +241,13 @@ private fun VaultListFilterButton(
val count = (state.content as? VaultListState.Content.Items)?.count val count = (state.content as? VaultListState.Content.Items)?.count
val filters = state.filters val filters = state.filters
val clearFilters = state.clearFilters val clearFilters = state.clearFilters
val saveFilters = state.saveFilters
FilterButton( FilterButton(
modifier = modifier, modifier = modifier,
count = count, count = count,
items = filters, items = filters,
onClear = clearFilters, onClear = clearFilters,
onSave = saveFilters,
) )
} }

View File

@ -21,6 +21,7 @@ data class VaultListState(
val query: TextFieldModel2 = TextFieldModel2(mutableStateOf("")), val query: TextFieldModel2 = TextFieldModel2(mutableStateOf("")),
val filters: List<FilterItem> = emptyList(), val filters: List<FilterItem> = emptyList(),
val sort: List<SortItem> = emptyList(), val sort: List<SortItem> = emptyList(),
val saveFilters: (() -> Unit)? = null,
val clearFilters: (() -> Unit)? = null, val clearFilters: (() -> Unit)? = null,
val clearSort: (() -> Unit)? = null, val clearSort: (() -> Unit)? = null,
val selectCipher: ((DSecret) -> Unit)? = null, val selectCipher: ((DSecret) -> Unit)? = null,

View File

@ -76,6 +76,7 @@ import com.artemchep.keyguard.feature.decorator.ItemDecoratorDate
import com.artemchep.keyguard.feature.decorator.ItemDecoratorNone import com.artemchep.keyguard.feature.decorator.ItemDecoratorNone
import com.artemchep.keyguard.feature.decorator.ItemDecoratorTitle import com.artemchep.keyguard.feature.decorator.ItemDecoratorTitle
import com.artemchep.keyguard.feature.duplicates.list.createCipherSelectionFlow 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.generator.history.mapLatestScoped
import com.artemchep.keyguard.feature.home.vault.VaultRoute import com.artemchep.keyguard.feature.home.vault.VaultRoute
import com.artemchep.keyguard.feature.home.vault.add.AddRoute import com.artemchep.keyguard.feature.home.vault.add.AddRoute
@ -387,7 +388,7 @@ fun vaultListScreenState(
var scrollPositionKey: Any? = null var scrollPositionKey: Any? = null
val scrollPositionSink = mutablePersistedFlow<OhOhOh>("scroll_state") { OhOhOh() } val scrollPositionSink = mutablePersistedFlow<OhOhOh>("scroll_state") { OhOhOh() }
val filterResult = createFilter() val filterResult = createFilter(directDI)
val actionsFlow = kotlin.run { val actionsFlow = kotlin.run {
val actionTrashItem = FlatItemAction( val actionTrashItem = FlatItemAction(
leading = { leading = {
@ -429,9 +430,15 @@ fun vaultListScreenState(
}, },
) )
val actionDownloadsFlow = flowOf(actionDownloadsItem) val actionDownloadsFlow = flowOf(actionDownloadsItem)
val actionFiltersItem = CipherFiltersRoute.actionOrNull(
translator = this,
navigate = ::navigate,
)
val actionFiltersFlow = flowOf(actionFiltersItem)
val actionGroupFlow = combine( val actionGroupFlow = combine(
actionTrashFlow, actionTrashFlow,
actionDownloadsFlow, actionDownloadsFlow,
actionFiltersFlow,
) { array -> ) { array ->
buildContextItems { buildContextItems {
section { section {
@ -1223,15 +1230,15 @@ fun vaultListScreenState(
.map { .map {
val keepOtp = it.filterConfig?.filter val keepOtp = it.filterConfig?.filter
?.let { ?.let {
DFilter.findOne(it, DFilter.ByOtp::class.java) DFilter.findAny<DFilter.ByOtp>(it)
} != null } != null
val keepAttachment = it.filterConfig?.filter val keepAttachment = it.filterConfig?.filter
?.let { ?.let {
DFilter.findOne(it, DFilter.ByAttachments::class.java) DFilter.findAny<DFilter.ByAttachments>(it)
} != null } != null
val keepPasskey = it.filterConfig?.filter val keepPasskey = it.filterConfig?.filter
?.let { ?.let {
DFilter.findOne(it, DFilter.ByPasskeys::class.java) DFilter.findAny<DFilter.ByPasskeys>(it)
} != null || } != null ||
// If a user is in the pick a passkey mode, // If a user is in the pick a passkey mode,
// then we want to always show it in the items. // then we want to always show it in the items.
@ -1511,6 +1518,7 @@ fun vaultListScreenState(
} else { } else {
emptyList() emptyList()
}, },
saveFilters = filters.onSave,
clearFilters = filters.onClear, clearFilters = filters.onClear,
clearSort = if (sortDefault != sort) { clearSort = if (sortDefault != sort) {
{ {

View File

@ -9,6 +9,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape 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.DropdownMenu
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@ -18,6 +21,7 @@ import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -34,6 +38,7 @@ fun <T> DropdownButton(
title: String, title: String,
items: List<T>, items: List<T>,
onClear: (() -> Unit)?, onClear: (() -> Unit)?,
onSave: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
var isExpanded by remember { mutableStateOf(false) } var isExpanded by remember { mutableStateOf(false) }
@ -44,6 +49,18 @@ fun <T> DropdownButton(
isExpanded = false isExpanded = false
} }
} }
val onExpandRequest = remember {
// lambda
{
isExpanded = true
}
}
val onDismissRequest = remember {
// lambda
{
isExpanded = false
}
}
Box( Box(
modifier = modifier, modifier = modifier,
@ -55,9 +72,7 @@ fun <T> DropdownButton(
IconButton( IconButton(
modifier = Modifier, modifier = Modifier,
enabled = items.isNotEmpty(), enabled = items.isNotEmpty(),
onClick = { onClick = onExpandRequest,
isExpanded = !isExpanded
},
) { ) {
Box { Box {
Icon(icon, null) Icon(icon, null)
@ -82,9 +97,6 @@ fun <T> DropdownButton(
// Inject the dropdown popup to the bottom of the // Inject the dropdown popup to the bottom of the
// content. // content.
val onDismissRequest = {
isExpanded = false
}
DropdownMenu( DropdownMenu(
modifier = Modifier modifier = Modifier
.widthIn( .widthIn(
@ -99,7 +111,43 @@ fun <T> DropdownButton(
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth(),
title = title, 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() content()

View File

@ -1,32 +1,24 @@
package com.artemchep.keyguard.feature.search.component package com.artemchep.keyguard.feature.search.component
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp 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 com.artemchep.keyguard.ui.theme.Dimens
import dev.icerock.moko.resources.compose.stringResource
@Composable @Composable
fun DropdownHeader( fun DropdownHeader(
title: String, title: String,
onClear: (() -> Unit)?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
actions: @Composable RowScope.() -> Unit,
) { ) {
Row( Row(
modifier = modifier modifier = modifier
@ -40,29 +32,11 @@ fun DropdownHeader(
text = title, text = title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
val updatedOnClear by rememberUpdatedState(onClear)
ExpandedIfNotEmptyForRow(
valueOrNull = onClear,
) {
TextButton(
onClick = {
updatedOnClear?.invoke()
},
) {
Icon(
imageVector = Icons.Outlined.Clear,
contentDescription = null,
)
Spacer( Spacer(
modifier = Modifier modifier = Modifier
.width(Dimens.buttonIconPadding), .width(8.dp),
) )
Text( actions()
text = stringResource(Res.strings.reset),
)
}
}
Spacer( Spacer(
modifier = Modifier modifier = Modifier
.width(8.dp), .width(8.dp),

View File

@ -16,6 +16,7 @@ fun FilterButton(
count: Int?, count: Int?,
items: List<FilterItemModel>, items: List<FilterItemModel>,
onClear: (() -> Unit)?, onClear: (() -> Unit)?,
onSave: (() -> Unit)?,
) { ) {
DropdownButton( DropdownButton(
modifier = modifier, modifier = modifier,
@ -23,6 +24,7 @@ fun FilterButton(
title = stringResource(Res.strings.filter_header_title), title = stringResource(Res.strings.filter_header_title),
items = items, items = items,
onClear = onClear, onClear = onClear,
onSave = onSave,
) { ) {
VaultHomeScreenFilterPaneNumberOfItems( VaultHomeScreenFilterPaneNumberOfItems(
count = count, count = count,

View File

@ -67,6 +67,7 @@ private fun FilterItemItem(
}, },
), ),
checked = item.checked, checked = item.checked,
enabled = item.enabled,
leading = item.leading, leading = item.leading,
title = item.title, title = item.title,
text = item.text, text = item.text,
@ -79,6 +80,7 @@ private fun FilterItemSection(
item: FilterItemModel.Section, item: FilterItemModel.Section,
) { ) {
FilterSectionComposable( FilterSectionComposable(
expandable = item.expandable,
expanded = item.expanded, expanded = item.expanded,
title = item.text, title = item.text,
onClick = item.onClick, onClick = item.onClick,

View File

@ -1,23 +1,28 @@
package com.artemchep.keyguard.feature.search.filter 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.Icons
import androidx.compose.material.icons.outlined.Clear import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll 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.component.VaultHomeScreenFilterPaneNumberOfItems
import com.artemchep.keyguard.feature.search.filter.model.FilterItemModel import com.artemchep.keyguard.feature.search.filter.model.FilterItemModel
import com.artemchep.keyguard.res.Res import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.DefaultFab import com.artemchep.keyguard.ui.DefaultFab
import com.artemchep.keyguard.ui.FabState import com.artemchep.keyguard.ui.FabState
import com.artemchep.keyguard.ui.ScaffoldColumn import com.artemchep.keyguard.ui.ScaffoldColumn
import com.artemchep.keyguard.ui.SmallFab
import com.artemchep.keyguard.ui.icons.IconBox import com.artemchep.keyguard.ui.icons.IconBox
import com.artemchep.keyguard.ui.toolbar.SmallToolbar
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
@Composable @Composable
@ -26,25 +31,13 @@ fun FilterScreen(
count: Int?, count: Int?,
items: List<FilterItemModel>, items: List<FilterItemModel>,
onClear: (() -> Unit)?, onClear: (() -> Unit)?,
actions: @Composable RowScope.() -> Unit, onSave: (() -> Unit)? = null,
) { ) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
ScaffoldColumn( ScaffoldColumn(
modifier = modifier modifier = modifier
.nestedScroll(scrollBehavior.nestedScrollConnection), .nestedScroll(scrollBehavior.nestedScrollConnection),
topAppBarScrollBehavior = scrollBehavior, topAppBarScrollBehavior = scrollBehavior,
// topBar = {
// SmallToolbar(
// title = {
// Text(
// text = stringResource(Res.strings.filter_header_title),
// style = MaterialTheme.typography.titleMedium,
// )
// },
// actions = actions,
// scrollBehavior = scrollBehavior,
// )
// },
floatingActionState = run { floatingActionState = run {
val fabState = if (onClear != null) { val fabState = if (onClear != null) {
FabState( FabState(
@ -57,6 +50,19 @@ fun FilterScreen(
rememberUpdatedState(newValue = fabState) rememberUpdatedState(newValue = fabState)
}, },
floatingActionButton = { floatingActionButton = {
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( DefaultFab(
icon = { icon = {
IconBox(main = Icons.Outlined.Clear) IconBox(main = Icons.Outlined.Clear)
@ -68,6 +74,7 @@ fun FilterScreen(
}, },
color = MaterialTheme.colorScheme.secondaryContainer, color = MaterialTheme.colorScheme.secondaryContainer,
) )
}
}, },
) { ) {
VaultHomeScreenFilterPaneNumberOfItems( VaultHomeScreenFilterPaneNumberOfItems(

View File

@ -42,6 +42,7 @@ fun FilterItemComposable(
title: String, title: String,
text: String?, text: String?,
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
enabled: Boolean = onClick != null,
) { ) {
FilterItemLayout( FilterItemLayout(
modifier = modifier, modifier = modifier,
@ -67,6 +68,7 @@ fun FilterItemComposable(
} }
}, },
onClick = onClick, onClick = onClick,
enabled = enabled,
) )
} }

View File

@ -22,6 +22,7 @@ import com.artemchep.keyguard.ui.theme.combineAlpha
@Composable @Composable
fun FilterSectionComposable( fun FilterSectionComposable(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
expandable: Boolean,
expanded: Boolean, expanded: Boolean,
title: String, title: String,
onClick: (() -> Unit)?, onClick: (() -> Unit)?,
@ -33,6 +34,10 @@ fun FilterSectionComposable(
vertical = 0.dp, vertical = 0.dp,
), ),
trailing = { trailing = {
if (!expandable) {
return@FlatItem
}
val targetRotationX = val targetRotationX =
if (expanded) { if (expanded) {
0f 0f
@ -63,5 +68,6 @@ fun FilterSectionComposable(
) )
}, },
onClick = onClick, onClick = onClick,
enabled = true,
) )
} }

View File

@ -7,8 +7,9 @@ interface FilterItemModel {
interface Section : FilterItemModel { interface Section : FilterItemModel {
val text: String val text: String
val expandable: Boolean
val expanded: Boolean val expanded: Boolean
val onClick: () -> Unit val onClick: (() -> Unit)?
} }
interface Item : FilterItemModel { interface Item : FilterItemModel {
@ -16,6 +17,7 @@ interface FilterItemModel {
val title: String val title: String
val text: String? val text: String?
val checked: Boolean val checked: Boolean
val enabled: Boolean
val fill: Boolean val fill: Boolean
val indent: Int val indent: Int
val onClick: (() -> Unit)? val onClick: (() -> Unit)?

View File

@ -19,6 +19,7 @@ data class SendListState(
val query: TextFieldModel2 = TextFieldModel2(mutableStateOf("")), val query: TextFieldModel2 = TextFieldModel2(mutableStateOf("")),
val filters: List<SendFilterItem> = emptyList(), val filters: List<SendFilterItem> = emptyList(),
val sort: List<SendSortItem> = emptyList(), val sort: List<SendSortItem> = emptyList(),
val saveFilters: (() -> Unit)? = null,
val clearFilters: (() -> Unit)? = null, val clearFilters: (() -> Unit)? = null,
val clearSort: (() -> Unit)? = null, val clearSort: (() -> Unit)? = null,
val showKeyboard: Boolean = false, val showKeyboard: Boolean = false,

View File

@ -843,6 +843,7 @@ fun sendListScreenState(
.takeIf { queryTrimmed.isEmpty() } .takeIf { queryTrimmed.isEmpty() }
.orEmpty(), .orEmpty(),
primaryActions = primaryActions, primaryActions = primaryActions,
saveFilters = null,
clearFilters = filters.onClear, clearFilters = filters.onClear,
clearSort = if (sortDefault != sort) { clearSort = if (sortDefault != sort) {
{ {

View File

@ -346,14 +346,6 @@ private fun SendListFilterScreen(
count = count, count = count,
items = filters, items = filters,
onClear = clearFilters, 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 count = (state.content as? SendListState.Content.Items)?.count
val filters = state.filters val filters = state.filters
val clearFilters = state.clearFilters val clearFilters = state.clearFilters
val saveFilters = state.saveFilters
FilterButton( FilterButton(
modifier = modifier, modifier = modifier,
count = count, count = count,
items = filters, items = filters,
onClear = clearFilters, onClear = clearFilters,
onSave = saveFilters,
) )
} }

View File

@ -14,8 +14,9 @@ sealed interface SendFilterItem : FilterItemModel {
data class Section( data class Section(
override val sectionId: String, override val sectionId: String,
override val text: String, override val text: String,
override val expandable: Boolean = true,
override val expanded: Boolean = true, override val expanded: Boolean = true,
override val onClick: () -> Unit, override val onClick: (() -> Unit)?,
) : SendFilterItem, FilterItemModel.Section { ) : SendFilterItem, FilterItemModel.Section {
companion object; companion object;
@ -33,6 +34,7 @@ sealed interface SendFilterItem : FilterItemModel {
override val title: String, override val title: String,
override val text: String?, override val text: String?,
override val onClick: (() -> Unit)?, override val onClick: (() -> Unit)?,
override val enabled: Boolean = onClick != null,
) : SendFilterItem, FilterItemModel.Item { ) : SendFilterItem, FilterItemModel.Item {
companion object; companion object;

View File

@ -24,6 +24,7 @@ import com.artemchep.keyguard.feature.confirmation.ConfirmationRoute
import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogIntent import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogIntent
import com.artemchep.keyguard.feature.crashlytics.crashlyticsAttempt import com.artemchep.keyguard.feature.crashlytics.crashlyticsAttempt
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon 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.NavigationIntent
import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver
import com.artemchep.keyguard.feature.navigation.state.produceScreenState import com.artemchep.keyguard.feature.navigation.state.produceScreenState
@ -285,18 +286,7 @@ fun produceUrlOverrideListState(
) )
} }
} }
val icon = VaultItemIcon.TextIcon( val icon = VaultItemIcon.TextIcon.short(it.name)
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 selectableFlow = selectionHandle val selectableFlow = selectionHandle
.idsFlow .idsFlow

View File

@ -33,12 +33,10 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AccountTree import androidx.compose.material.icons.outlined.AccountTree
import androidx.compose.material.icons.outlined.CheckCircleOutline import androidx.compose.material.icons.outlined.CheckCircleOutline
import androidx.compose.material.icons.outlined.CopyAll 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.Delete
import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.FolderOff import androidx.compose.material.icons.outlined.FolderOff
import androidx.compose.material.icons.outlined.Info 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.ShortText
import androidx.compose.material.icons.outlined.Timer import androidx.compose.material.icons.outlined.Timer
import androidx.compose.material.icons.outlined.Warning 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.OptionsButton
import com.artemchep.keyguard.ui.animatedNumberText import com.artemchep.keyguard.ui.animatedNumberText
import com.artemchep.keyguard.ui.grid.preferredGridWidth 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.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.icons.KeyguardWebsite
import com.artemchep.keyguard.ui.poweredby.PoweredBy2factorauth import com.artemchep.keyguard.ui.poweredby.PoweredBy2factorauth
import com.artemchep.keyguard.ui.poweredby.PoweredByHaveibeenpwned import com.artemchep.keyguard.ui.poweredby.PoweredByHaveibeenpwned
@ -211,8 +219,6 @@ fun VaultHomeScreenFilterPaneCard2(
count = null, count = null,
items = items, items = items,
onClear = onClear, onClear = onClear,
actions = {
},
) )
} }
@ -397,6 +403,7 @@ private fun VaultHomeScreenFilterButton(
modifier = modifier, modifier = modifier,
items = state.filter.items, items = state.filter.items,
onClear = state.filter.onClear, onClear = state.filter.onClear,
onSave = state.filter.onSave,
) )
} }
@ -405,12 +412,14 @@ fun VaultHomeScreenFilterButton2(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
items: List<FilterItem>, items: List<FilterItem>,
onClear: (() -> Unit)?, onClear: (() -> Unit)?,
onSave: (() -> Unit)?,
) { ) {
FilterButton( FilterButton(
modifier = modifier, modifier = modifier,
count = null, count = null,
items = items, items = items,
onClear = onClear, onClear = onClear,
onSave = onSave,
) )
} }
@ -701,7 +710,7 @@ private fun CardPwnedPassword(
) )
}, },
text = stringResource(Res.strings.watchtower_item_pwned_passwords_text), text = stringResource(Res.strings.watchtower_item_pwned_passwords_text),
imageVector = Icons.Outlined.DataArray, imageVector = Icons.Outlined.KeyguardPwnedPassword,
onClick = state.onClick, onClick = state.onClick,
content = { content = {
PoweredByHaveibeenpwned( PoweredByHaveibeenpwned(
@ -727,7 +736,7 @@ private fun CardReusedPassword(
) )
}, },
text = stringResource(Res.strings.watchtower_item_reused_passwords_text), text = stringResource(Res.strings.watchtower_item_reused_passwords_text),
imageVector = Icons.Outlined.Recycling, imageVector = Icons.Outlined.KeyguardReusedPassword,
onClick = state.onClick, onClick = state.onClick,
) )
} }
@ -746,7 +755,7 @@ private fun CardVulnerableAccounts(
) )
}, },
text = stringResource(Res.strings.watchtower_item_vulnerable_accounts_text), text = stringResource(Res.strings.watchtower_item_vulnerable_accounts_text),
imageVector = Icons.Outlined.KeyguardWebsite, imageVector = Icons.Outlined.KeyguardPwnedWebsites,
onClick = state.onClick, onClick = state.onClick,
content = { content = {
PoweredByHaveibeenpwned( PoweredByHaveibeenpwned(
@ -772,7 +781,7 @@ private fun CardUnsecureWebsites(
) )
}, },
text = stringResource(Res.strings.watchtower_item_unsecure_websites_text), text = stringResource(Res.strings.watchtower_item_unsecure_websites_text),
imageVector = Icons.Outlined.KeyguardWebsite, imageVector = Icons.Outlined.KeyguardUnsecureWebsites,
onClick = state.onClick, onClick = state.onClick,
) )
} }
@ -843,7 +852,7 @@ private fun CardInactivePasskey(
) )
}, },
text = stringResource(Res.strings.watchtower_item_inactive_passkey_text), text = stringResource(Res.strings.watchtower_item_inactive_passkey_text),
imageVector = Icons.Outlined.KeyguardTwoFa, imageVector = Icons.Outlined.KeyguardPasskey,
onClick = state.onClick, onClick = state.onClick,
content = { content = {
PoweredByPasskeys( PoweredByPasskeys(
@ -869,7 +878,7 @@ private fun CardIncompleteItems(
) )
}, },
text = stringResource(Res.strings.watchtower_item_incomplete_items_text), text = stringResource(Res.strings.watchtower_item_incomplete_items_text),
imageVector = Icons.Outlined.ShortText, imageVector = Icons.Outlined.KeyguardIncompleteItems,
onClick = state.onClick, onClick = state.onClick,
) )
} }
@ -888,7 +897,7 @@ private fun CardExpiringItems(
) )
}, },
text = stringResource(Res.strings.watchtower_item_expiring_items_text), text = stringResource(Res.strings.watchtower_item_expiring_items_text),
imageVector = Icons.Outlined.Timer, imageVector = Icons.Outlined.KeyguardExpiringItems,
onClick = state.onClick, onClick = state.onClick,
) )
} }
@ -907,7 +916,7 @@ private fun CardDuplicateWebsites(
) )
}, },
text = stringResource(Res.strings.watchtower_item_duplicate_websites_text), text = stringResource(Res.strings.watchtower_item_duplicate_websites_text),
imageVector = Icons.Outlined.KeyguardWebsite, imageVector = Icons.Outlined.KeyguardDuplicateWebsites,
onClick = state.onClick, onClick = state.onClick,
) )
} }
@ -934,7 +943,7 @@ private fun CardTrashedItems(
) )
}, },
text = stringResource(Res.strings.watchtower_item_trashed_items_text), text = stringResource(Res.strings.watchtower_item_trashed_items_text),
imageVector = Icons.Outlined.Delete, imageVector = Icons.Outlined.KeyguardTrashedItems,
onClick = state.onClick, onClick = state.onClick,
) )
} }
@ -977,7 +986,7 @@ private fun CardDuplicateItems(
) )
}, },
text = stringResource(Res.strings.watchtower_item_duplicate_items_text), text = stringResource(Res.strings.watchtower_item_duplicate_items_text),
imageVector = Icons.Outlined.CopyAll, imageVector = Icons.Outlined.KeyguardDuplicateItems,
onClick = state.onClick, onClick = state.onClick,
) )
} }

View File

@ -14,6 +14,7 @@ data class WatchtowerState(
data class Filter( data class Filter(
val items: List<FilterItem> = emptyList(), val items: List<FilterItem> = emptyList(),
val onClear: (() -> Unit)? = null, val onClear: (() -> Unit)? = null,
val onSave: (() -> Unit)? = null,
) )
data class Content( data class Content(

View File

@ -141,7 +141,7 @@ fun produceWatchtowerState(
} }
.shareIn(screenScope, SharingStarted.WhileSubscribed(), replay = 1) .shareIn(screenScope, SharingStarted.WhileSubscribed(), replay = 1)
val filterResult = createFilter() val filterResult = createFilter(directDI)
fun filteredCiphers(ciphersFlow: Flow<List<DSecret>>) = ciphersFlow fun filteredCiphers(ciphersFlow: Flow<List<DSecret>>) = ciphersFlow
.map { .map {
@ -993,6 +993,7 @@ fun produceWatchtowerState(
filter = WatchtowerState.Filter( filter = WatchtowerState.Filter(
items = filterState.items, items = filterState.items,
onClear = filterState.onClear, onClear = filterState.onClear,
onSave = filterState.onSave,
), ),
actions = actions, actions = actions,
) )

View File

@ -8,3 +8,6 @@ fun Platform.hasAutofill(): Boolean =
fun Platform.hasSubscription(): Boolean = fun Platform.hasSubscription(): Boolean =
this is Platform.Mobile.Android this is Platform.Mobile.Android
fun Platform.hasDynamicShortcuts(): Boolean =
this is Platform.Mobile.Android

View File

@ -5,15 +5,26 @@ import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath import androidx.compose.material.icons.materialPath
import androidx.compose.material.icons.outlined.AccountTree import androidx.compose.material.icons.outlined.AccountTree
import androidx.compose.material.icons.outlined.Attachment 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.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.Lens
import androidx.compose.material.icons.outlined.LibraryBooks import androidx.compose.material.icons.outlined.LibraryBooks
import androidx.compose.material.icons.outlined.List 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.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.Star
import androidx.compose.material.icons.outlined.StarBorder import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material.icons.outlined.StickyNote2 import androidx.compose.material.icons.outlined.StickyNote2
import androidx.compose.material.icons.outlined.Timer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import compose.icons.FeatherIcons import compose.icons.FeatherIcons
import compose.icons.feathericons.Eye import compose.icons.feathericons.Eye
@ -28,6 +39,9 @@ val Icons.Outlined.KeyguardView
val Icons.Outlined.KeyguardTwoFa val Icons.Outlined.KeyguardTwoFa
get() = Numbers get() = Numbers
val Icons.Outlined.KeyguardPasskey
get() = Key
val Icons.Outlined.KeyguardNote val Icons.Outlined.KeyguardNote
get() = StickyNote2 get() = StickyNote2
@ -58,6 +72,48 @@ val Icons.Outlined.KeyguardPremium
val Icons.Outlined.KeyguardWordlist val Icons.Outlined.KeyguardWordlist
get() = LibraryBooks 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 val Icons.Stub: ImageVector
get() { get() {
if (_stub != null) { if (_stub != null) {

View File

@ -192,6 +192,9 @@
<string name="master_password_hint">Master password hint</string> <string name="master_password_hint">Master password hint</string>
<string name="fingerprint_phrase">Fingerprint phrase</string> <string name="fingerprint_phrase">Fingerprint phrase</string>
<string name="fingerprint_phrase_help_title">What is a fingerprint?</string> <string name="fingerprint_phrase_help_title">What is a fingerprint?</string>
<string name="filter_auth_reprompt_items">Auth re-prompt</string>
<string name="filter_pending_items">Pending</string>
<string name="filter_failed_items">Failed</string>
<string name="bitwarden_premium">Bitwarden premium</string> <string name="bitwarden_premium">Bitwarden premium</string>
<string name="bitwarden_unofficial_server">Unofficial Bitwarden server</string> <string name="bitwarden_unofficial_server">Unofficial Bitwarden server</string>
@ -671,6 +674,14 @@
<string name="passwords_fair_label">Fair passwords</string> <string name="passwords_fair_label">Fair passwords</string>
<string name="passwords_weak_label">Weak passwords</string> <string name="passwords_weak_label">Weak passwords</string>
<string name="customfilters_header_title">Custom filters</string>
<string name="customfilters_search_placeholder">Search filters</string>
<string name="customfilters_dynamic_shortcut_tip">You can quickly apply filters by adding a shortcut on your home screen.</string>
<string name="customfilters_delete_one_confirmation_title">Delete the filter?</string>
<string name="customfilters_delete_many_confirmation_title">Delete the filters?</string>
<string name="customfilters_edit_filter_title">Edit a custom filter</string>
<string name="customfilters_add_filter_title">Add a custom filter</string>
<string name="filter_header_title">Filter</string> <string name="filter_header_title">Filter</string>
<string name="filter_empty_label">No filters available</string> <string name="filter_empty_label">No filters available</string>

View File

@ -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);

View File

@ -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
);

View File

@ -28,6 +28,7 @@ androidxBrowser = "1.7.0"
androidxCamera = "1.4.0-alpha04" androidxCamera = "1.4.0-alpha04"
androidxCoreKtx = "1.12.0" androidxCoreKtx = "1.12.0"
androidxCoreSplash = "1.1.0-alpha02" androidxCoreSplash = "1.1.0-alpha02"
androidxCoreShortcuts = "1.0.0"
androidxCredentials = "1.2.0" androidxCredentials = "1.2.0"
androidxDatastore = "1.0.0" androidxDatastore = "1.0.0"
androidxLifecycle = "2.7.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-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidxCamera" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCoreKtx" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCoreKtx" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidxCoreSplash" } 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-credentials = { module = "androidx.credentials:credentials", version.ref = "androidxCredentials" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidxDatastore" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidxDatastore" }
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxTestEspresso" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxTestEspresso" }