feat: Custom filters
This commit is contained in:
parent
f5c3daebcc
commit
4ecd4f0b91
|
@ -1,11 +1,16 @@
|
|||
package com.artemchep.keyguard
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.artemchep.bindin.bindBlock
|
||||
import com.artemchep.keyguard.android.BaseApp
|
||||
import com.artemchep.keyguard.android.MainActivity
|
||||
import com.artemchep.keyguard.android.downloader.journal.DownloadRepository
|
||||
import com.artemchep.keyguard.android.downloader.worker.AttachmentDownloadAllWorker
|
||||
import com.artemchep.keyguard.android.passkeysModule
|
||||
|
@ -22,6 +27,7 @@ import com.artemchep.keyguard.core.session.diFingerprintRepositoryModule
|
|||
import com.artemchep.keyguard.common.model.MasterSession
|
||||
import com.artemchep.keyguard.common.service.vault.KeyReadWriteRepository
|
||||
import com.artemchep.keyguard.common.model.PersistedSession
|
||||
import com.artemchep.keyguard.common.service.filter.GetCipherFilters
|
||||
import com.artemchep.keyguard.feature.favicon.Favicon
|
||||
import com.artemchep.keyguard.feature.localization.textResource
|
||||
import com.artemchep.keyguard.platform.LeContext
|
||||
|
@ -223,5 +229,53 @@ class Main : BaseApp(), DIAware {
|
|||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
|
||||
// shortcuts
|
||||
ProcessLifecycleOwner.get().lifecycleScope.launch {
|
||||
getVaultSession()
|
||||
.flatMapLatest { session ->
|
||||
when (session) {
|
||||
is MasterSession.Key -> {
|
||||
val getCipherFilters: GetCipherFilters = session.di.direct.instance()
|
||||
getCipherFilters()
|
||||
}
|
||||
|
||||
is MasterSession.Empty -> emptyFlow()
|
||||
}
|
||||
}
|
||||
.onEach { filters ->
|
||||
val dynamicShortcutsIdsToRemove = kotlin.run {
|
||||
val oldDynamicShortcutsIds =
|
||||
ShortcutManagerCompat.getDynamicShortcuts(this@Main)
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
val newDynamicShortcutsIds = filters
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
oldDynamicShortcutsIds - newDynamicShortcutsIds
|
||||
}
|
||||
if (dynamicShortcutsIdsToRemove.isNotEmpty()) {
|
||||
val ids = dynamicShortcutsIdsToRemove.toList()
|
||||
ShortcutManagerCompat.removeDynamicShortcuts(this@Main, ids)
|
||||
}
|
||||
|
||||
val shortcuts = filters
|
||||
.map {
|
||||
val intent = MainActivity.getIntent(this@Main).apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
putExtra("customFilter", it.id)
|
||||
}
|
||||
val icon = IconCompat.createWithResource(this@Main, com.artemchep.keyguard.common.R.drawable.ic_shortcut_keyguard)
|
||||
ShortcutInfoCompat.Builder(this@Main, it.id)
|
||||
.setIcon(icon)
|
||||
.setShortLabel(it.name)
|
||||
.setIntent(intent)
|
||||
.addCapabilityBinding("actions.intent.OPEN_APP_FEATURE")
|
||||
.build()
|
||||
}
|
||||
ShortcutManagerCompat.addDynamicShortcuts(this@Main, shortcuts)
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -127,6 +127,7 @@ kotlin {
|
|||
api(libs.androidx.browser)
|
||||
api(libs.androidx.core.ktx)
|
||||
api(libs.androidx.core.splashscreen)
|
||||
api(libs.androidx.core.shortcuts)
|
||||
api(libs.androidx.credentials)
|
||||
api(libs.androidx.datastore)
|
||||
api(libs.androidx.lifecycle.livedata.ktx)
|
||||
|
|
|
@ -5,6 +5,6 @@
|
|||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<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" />
|
||||
</vector>
|
|
@ -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>
|
|
@ -1,20 +1,2 @@
|
|||
<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>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -1,5 +1,9 @@
|
|||
package com.artemchep.keyguard.common.model
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Key
|
||||
import androidx.compose.material.icons.outlined.Password
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import arrow.core.Either
|
||||
import arrow.core.getOrElse
|
||||
import arrow.core.partially1
|
||||
|
@ -22,11 +26,29 @@ import com.artemchep.keyguard.common.usecase.CipherUnsecureUrlCheck
|
|||
import com.artemchep.keyguard.common.usecase.CipherUrlDuplicateCheck
|
||||
import com.artemchep.keyguard.core.store.bitwarden.exists
|
||||
import com.artemchep.keyguard.feature.crashlytics.crashlyticsTap
|
||||
import com.artemchep.keyguard.feature.home.vault.component.obscurePassword
|
||||
import com.artemchep.keyguard.feature.localization.TextHolder
|
||||
import com.artemchep.keyguard.provider.bitwarden.entity.HibpBreachGroup
|
||||
import com.artemchep.keyguard.res.Res
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardAttachment
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardAuthReprompt
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardDuplicateWebsites
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardExpiringItems
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardFailedItems
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardIgnoredAlerts
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardIncompleteItems
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardPasskey
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardPendingSyncItems
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardPwnedPassword
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardPwnedWebsites
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardReusedPassword
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardTwoFa
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardUnsecureWebsites
|
||||
import io.ktor.http.Url
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import org.kodein.di.DirectDI
|
||||
import org.kodein.di.instance
|
||||
import kotlin.collections.Collection
|
||||
|
@ -89,7 +111,24 @@ sealed interface DFilter {
|
|||
_findOne(f, target, predicate)
|
||||
}
|
||||
|
||||
else -> Either.Left(Unit)
|
||||
else -> kotlin.run {
|
||||
filter.filters.forEach { f ->
|
||||
val r = _findOne(f, target, predicate)
|
||||
r.fold(
|
||||
ifLeft = {
|
||||
return@run r
|
||||
},
|
||||
ifRight = {
|
||||
if (it != null) {
|
||||
return@run Either.Left(Unit)
|
||||
}
|
||||
|
||||
it
|
||||
},
|
||||
)
|
||||
}
|
||||
Either.Right(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -128,6 +167,52 @@ sealed interface DFilter {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <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(
|
||||
|
@ -145,6 +230,21 @@ sealed interface DFilter {
|
|||
val key: String
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed interface PrimitiveSpecial : Primitive {
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed interface PrimitiveSimple : Primitive {
|
||||
data class Content(
|
||||
val title: TextHolder,
|
||||
val icon: ImageVector? = null,
|
||||
)
|
||||
|
||||
@Transient
|
||||
val content: Content
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@SerialName("or")
|
||||
data class Or<out T : DFilter>(
|
||||
|
@ -258,7 +358,8 @@ sealed interface DFilter {
|
|||
data class ById(
|
||||
val id: String?,
|
||||
val what: What,
|
||||
) : Primitive {
|
||||
) : PrimitiveSpecial {
|
||||
@Transient
|
||||
override val key: String = "$id|$what"
|
||||
|
||||
@Serializable
|
||||
|
@ -328,10 +429,19 @@ sealed interface DFilter {
|
|||
@Serializable
|
||||
@SerialName("by_type")
|
||||
data class ByType(
|
||||
@SerialName("cipherType")
|
||||
val type: DSecret.Type,
|
||||
) : Primitive {
|
||||
) : PrimitiveSimple {
|
||||
@Transient
|
||||
override val key: String = "$type"
|
||||
|
||||
@Transient
|
||||
override val content = PrimitiveSimple.Content(
|
||||
title = type.titleH()
|
||||
.let(TextHolder::Res),
|
||||
icon = type.iconImageVector(),
|
||||
)
|
||||
|
||||
override suspend fun prepare(
|
||||
directDI: DirectDI,
|
||||
ciphers: List<DSecret>,
|
||||
|
@ -344,9 +454,17 @@ sealed interface DFilter {
|
|||
|
||||
@Serializable
|
||||
@SerialName("by_otp")
|
||||
data object ByOtp : Primitive {
|
||||
data object ByOtp : PrimitiveSimple {
|
||||
@Transient
|
||||
override val key: String = "otp"
|
||||
|
||||
@Transient
|
||||
override val content = PrimitiveSimple.Content(
|
||||
title = Res.strings.one_time_password
|
||||
.let(TextHolder::Res),
|
||||
icon = Icons.Outlined.KeyguardTwoFa,
|
||||
)
|
||||
|
||||
override suspend fun prepare(
|
||||
directDI: DirectDI,
|
||||
ciphers: List<DSecret>,
|
||||
|
@ -359,9 +477,17 @@ sealed interface DFilter {
|
|||
|
||||
@Serializable
|
||||
@SerialName("by_attachments")
|
||||
data object ByAttachments : Primitive {
|
||||
data object ByAttachments : PrimitiveSimple {
|
||||
@Transient
|
||||
override val key: String = "attachments"
|
||||
|
||||
@Transient
|
||||
override val content = PrimitiveSimple.Content(
|
||||
title = Res.strings.attachments
|
||||
.let(TextHolder::Res),
|
||||
icon = Icons.Outlined.KeyguardAttachment,
|
||||
)
|
||||
|
||||
override suspend fun prepare(
|
||||
directDI: DirectDI,
|
||||
ciphers: List<DSecret>,
|
||||
|
@ -374,9 +500,17 @@ sealed interface DFilter {
|
|||
|
||||
@Serializable
|
||||
@SerialName("by_passkeys")
|
||||
data object ByPasskeys : Primitive {
|
||||
data object ByPasskeys : PrimitiveSimple {
|
||||
@Transient
|
||||
override val key: String = "passkeys"
|
||||
|
||||
@Transient
|
||||
override val content = PrimitiveSimple.Content(
|
||||
title = Res.strings.passkeys
|
||||
.let(TextHolder::Res),
|
||||
icon = Icons.Outlined.Key,
|
||||
)
|
||||
|
||||
override suspend fun prepare(
|
||||
directDI: DirectDI,
|
||||
ciphers: List<DSecret>,
|
||||
|
@ -391,9 +525,17 @@ sealed interface DFilter {
|
|||
@SerialName("by_pwd_value")
|
||||
data class ByPasswordValue(
|
||||
val value: String?,
|
||||
) : Primitive {
|
||||
) : PrimitiveSimple {
|
||||
@Transient
|
||||
override val key: String = "pwd_value|$value"
|
||||
|
||||
@Transient
|
||||
override val content = PrimitiveSimple.Content(
|
||||
title = value?.let(::obscurePassword).orEmpty()
|
||||
.let(TextHolder::Value),
|
||||
icon = Icons.Outlined.Password,
|
||||
)
|
||||
|
||||
override suspend fun prepare(
|
||||
directDI: DirectDI,
|
||||
ciphers: List<DSecret>,
|
||||
|
@ -408,8 +550,22 @@ sealed interface DFilter {
|
|||
@SerialName("by_pwd_strength")
|
||||
data class ByPasswordStrength(
|
||||
val score: PasswordStrength.Score,
|
||||
) : Primitive {
|
||||
override val key: String = "$score"
|
||||
) : PrimitiveSimple {
|
||||
@Transient
|
||||
override val key: String = "pwd_score|$score"
|
||||
|
||||
@Transient
|
||||
override val content = PrimitiveSimple.Content(
|
||||
title = when (score) {
|
||||
PasswordStrength.Score.Weak -> Res.strings.passwords_weak_label
|
||||
PasswordStrength.Score.Fair -> Res.strings.passwords_fair_label
|
||||
PasswordStrength.Score.Good -> Res.strings.passwords_good_label
|
||||
PasswordStrength.Score.Strong -> Res.strings.passwords_strong_label
|
||||
PasswordStrength.Score.VeryStrong -> Res.strings.passwords_very_strong_label
|
||||
}
|
||||
.let(TextHolder::Res),
|
||||
icon = Icons.Outlined.Password,
|
||||
)
|
||||
|
||||
override suspend fun prepare(
|
||||
directDI: DirectDI,
|
||||
|
@ -423,9 +579,17 @@ sealed interface DFilter {
|
|||
|
||||
@Serializable
|
||||
@SerialName("by_pwd_duplicates")
|
||||
data object ByPasswordDuplicates : Primitive {
|
||||
data object ByPasswordDuplicates : PrimitiveSimple {
|
||||
@Transient
|
||||
override val key: String = "pwd_duplicates"
|
||||
|
||||
@Transient
|
||||
override val content = PrimitiveSimple.Content(
|
||||
title = Res.strings.watchtower_item_reused_passwords_title
|
||||
.let(TextHolder::Res),
|
||||
icon = Icons.Outlined.KeyguardReusedPassword,
|
||||
)
|
||||
|
||||
private data class DuplicatesState(
|
||||
var duplicate: Int,
|
||||
var ignored: Int,
|
||||
|
@ -497,9 +661,17 @@ sealed interface DFilter {
|
|||
|
||||
@Serializable
|
||||
@SerialName("by_pwd_pwned")
|
||||
data object ByPasswordPwned : Primitive {
|
||||
data object ByPasswordPwned : PrimitiveSimple {
|
||||
@Transient
|
||||
override val key: String = "pwd_pwned"
|
||||
|
||||
@Transient
|
||||
override val content = PrimitiveSimple.Content(
|
||||
title = Res.strings.watchtower_item_pwned_passwords_title
|
||||
.let(TextHolder::Res),
|
||||
icon = Icons.Outlined.KeyguardPwnedPassword,
|
||||
)
|
||||
|
||||
override suspend fun prepare(
|
||||
directDI: DirectDI,
|
||||
ciphers: List<DSecret>,
|
||||
|
@ -565,9 +737,17 @@ sealed interface DFilter {
|
|||
|
||||
@Serializable
|
||||
@SerialName("by_website_pwned")
|
||||
data object ByWebsitePwned : Primitive {
|
||||
data object ByWebsitePwned : PrimitiveSimple {
|
||||
@Transient
|
||||
override val key: String = "website_pwned"
|
||||
|
||||
@Transient
|
||||
override val content = PrimitiveSimple.Content(
|
||||
title = Res.strings.watchtower_item_vulnerable_accounts_title
|
||||
.let(TextHolder::Res),
|
||||
icon = Icons.Outlined.KeyguardPwnedWebsites,
|
||||
)
|
||||
|
||||
override suspend fun prepare(
|
||||
directDI: DirectDI,
|
||||
ciphers: List<DSecret>,
|
||||
|
@ -627,9 +807,17 @@ sealed interface DFilter {
|
|||
|
||||
@Serializable
|
||||
@SerialName("by_incomplete")
|
||||
data object ByIncomplete : Primitive {
|
||||
data object ByIncomplete : PrimitiveSimple {
|
||||
@Transient
|
||||
override val key: String = "incomplete"
|
||||
|
||||
@Transient
|
||||
override val content = PrimitiveSimple.Content(
|
||||
title = Res.strings.watchtower_item_incomplete_items_title
|
||||
.let(TextHolder::Res),
|
||||
icon = Icons.Outlined.KeyguardIncompleteItems,
|
||||
)
|
||||
|
||||
override suspend fun prepare(
|
||||
directDI: DirectDI,
|
||||
ciphers: List<DSecret>,
|
||||
|
@ -676,9 +864,17 @@ sealed interface DFilter {
|
|||
|
||||
@Serializable
|
||||
@SerialName("by_expiring")
|
||||
data object ByExpiring : Primitive {
|
||||
data object ByExpiring : PrimitiveSimple {
|
||||
@Transient
|
||||
override val key: String = "expiring"
|
||||
|
||||
@Transient
|
||||
override val content = PrimitiveSimple.Content(
|
||||
title = Res.strings.watchtower_item_expiring_items_title
|
||||
.let(TextHolder::Res),
|
||||
icon = Icons.Outlined.KeyguardExpiringItems,
|
||||
)
|
||||
|
||||
override suspend fun prepare(
|
||||
directDI: DirectDI,
|
||||
ciphers: List<DSecret>,
|
||||
|
@ -726,9 +922,17 @@ sealed interface DFilter {
|
|||
|
||||
@Serializable
|
||||
@SerialName("by_unsecure_websites")
|
||||
data object ByUnsecureWebsites : Primitive {
|
||||
data object ByUnsecureWebsites : PrimitiveSimple {
|
||||
@Transient
|
||||
override val key: String = "unsecure_websites"
|
||||
|
||||
@Transient
|
||||
override val content = PrimitiveSimple.Content(
|
||||
title = Res.strings.watchtower_item_unsecure_websites_title
|
||||
.let(TextHolder::Res),
|
||||
icon = Icons.Outlined.KeyguardUnsecureWebsites,
|
||||
)
|
||||
|
||||
override suspend fun prepare(
|
||||
directDI: DirectDI,
|
||||
ciphers: List<DSecret>,
|
||||
|
@ -777,9 +981,17 @@ sealed interface DFilter {
|
|||
|
||||
@Serializable
|
||||
@SerialName("by_tfa_websites")
|
||||
data object ByTfaWebsites : Primitive {
|
||||
data object ByTfaWebsites : PrimitiveSimple {
|
||||
@Transient
|
||||
override val key: String = "tfa_websites"
|
||||
|
||||
@Transient
|
||||
override val content = PrimitiveSimple.Content(
|
||||
title = Res.strings.watchtower_item_inactive_2fa_title
|
||||
.let(TextHolder::Res),
|
||||
icon = Icons.Outlined.KeyguardTwoFa,
|
||||
)
|
||||
|
||||
override suspend fun prepare(
|
||||
directDI: DirectDI,
|
||||
ciphers: List<DSecret>,
|
||||
|
@ -874,9 +1086,17 @@ sealed interface DFilter {
|
|||
|
||||
@Serializable
|
||||
@SerialName("by_passkey_websites")
|
||||
data object ByPasskeyWebsites : Primitive {
|
||||
data object ByPasskeyWebsites : PrimitiveSimple {
|
||||
@Transient
|
||||
override val key: String = "passkey_websites"
|
||||
|
||||
@Transient
|
||||
override val content = PrimitiveSimple.Content(
|
||||
title = Res.strings.watchtower_item_inactive_passkey_title
|
||||
.let(TextHolder::Res),
|
||||
icon = Icons.Outlined.KeyguardPasskey,
|
||||
)
|
||||
|
||||
override suspend fun prepare(
|
||||
directDI: DirectDI,
|
||||
ciphers: List<DSecret>,
|
||||
|
@ -983,9 +1203,17 @@ sealed interface DFilter {
|
|||
|
||||
@Serializable
|
||||
@SerialName("by_duplicate_websites")
|
||||
data object ByDuplicateWebsites : Primitive {
|
||||
data object ByDuplicateWebsites : PrimitiveSimple {
|
||||
@Transient
|
||||
override val key: String = "duplicate_websites"
|
||||
|
||||
@Transient
|
||||
override val content = PrimitiveSimple.Content(
|
||||
title = Res.strings.watchtower_item_duplicate_websites_title
|
||||
.let(TextHolder::Res),
|
||||
icon = Icons.Outlined.KeyguardDuplicateWebsites,
|
||||
)
|
||||
|
||||
override suspend fun prepare(
|
||||
directDI: DirectDI,
|
||||
ciphers: List<DSecret>,
|
||||
|
@ -1049,9 +1277,17 @@ sealed interface DFilter {
|
|||
@SerialName("by_sync")
|
||||
data class BySync(
|
||||
val synced: Boolean,
|
||||
) : Primitive {
|
||||
) : PrimitiveSimple {
|
||||
@Transient
|
||||
override val key: String = "$synced"
|
||||
|
||||
@Transient
|
||||
override val content = PrimitiveSimple.Content(
|
||||
title = Res.strings.filter_pending_items
|
||||
.let(TextHolder::Res),
|
||||
icon = Icons.Outlined.KeyguardPendingSyncItems,
|
||||
)
|
||||
|
||||
override suspend fun prepare(
|
||||
directDI: DirectDI,
|
||||
ciphers: List<DSecret>,
|
||||
|
@ -1075,9 +1311,17 @@ sealed interface DFilter {
|
|||
@SerialName("by_repromt")
|
||||
data class ByReprompt(
|
||||
val reprompt: Boolean,
|
||||
) : Primitive {
|
||||
) : PrimitiveSimple {
|
||||
@Transient
|
||||
override val key: String = "$reprompt"
|
||||
|
||||
@Transient
|
||||
override val content = PrimitiveSimple.Content(
|
||||
title = Res.strings.filter_auth_reprompt_items
|
||||
.let(TextHolder::Res),
|
||||
icon = Icons.Outlined.KeyguardAuthReprompt,
|
||||
)
|
||||
|
||||
override suspend fun prepare(
|
||||
directDI: DirectDI,
|
||||
ciphers: List<DSecret>,
|
||||
|
@ -1092,9 +1336,17 @@ sealed interface DFilter {
|
|||
@SerialName("by_error")
|
||||
data class ByError(
|
||||
val error: Boolean,
|
||||
) : Primitive {
|
||||
) : PrimitiveSimple {
|
||||
@Transient
|
||||
override val key: String = "$error"
|
||||
|
||||
@Transient
|
||||
override val content = PrimitiveSimple.Content(
|
||||
title = Res.strings.filter_failed_items
|
||||
.let(TextHolder::Res),
|
||||
icon = Icons.Outlined.KeyguardFailedItems,
|
||||
)
|
||||
|
||||
override suspend fun prepare(
|
||||
directDI: DirectDI,
|
||||
ciphers: List<DSecret>,
|
||||
|
@ -1116,9 +1368,17 @@ sealed interface DFilter {
|
|||
|
||||
@Serializable
|
||||
@SerialName("by_ignored_alerts")
|
||||
data object ByIgnoredAlerts : Primitive {
|
||||
data object ByIgnoredAlerts : PrimitiveSimple {
|
||||
@Transient
|
||||
override val key: String = "ignored_alerts"
|
||||
|
||||
@Transient
|
||||
override val content = PrimitiveSimple.Content(
|
||||
title = Res.strings.ignored_alerts
|
||||
.let(TextHolder::Res),
|
||||
icon = Icons.Outlined.KeyguardIgnoredAlerts,
|
||||
)
|
||||
|
||||
override suspend fun prepare(
|
||||
directDI: DirectDI,
|
||||
ciphers: List<DSecret>,
|
||||
|
|
|
@ -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>
|
|
@ -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>>
|
|
@ -0,0 +1,7 @@
|
|||
package com.artemchep.keyguard.common.service.filter
|
||||
|
||||
import com.artemchep.keyguard.common.io.IO
|
||||
|
||||
interface RemoveCipherFilterById : (
|
||||
Set<Long>,
|
||||
) -> IO<Unit>
|
|
@ -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>
|
|
@ -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>>,
|
||||
)
|
|
@ -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)
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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>>,
|
||||
)
|
|
@ -0,0 +1,6 @@
|
|||
package com.artemchep.keyguard.common.service.filter.model
|
||||
|
||||
data class RenameCipherFilterRequest(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
)
|
|
@ -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>
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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'
|
|
@ -5,6 +5,16 @@ import com.artemchep.keyguard.android.downloader.journal.CipherHistoryOpenedRepo
|
|||
import com.artemchep.keyguard.android.downloader.journal.GeneratorHistoryRepository
|
||||
import com.artemchep.keyguard.android.downloader.journal.GeneratorHistoryRepositoryImpl
|
||||
import com.artemchep.keyguard.common.model.MasterKey
|
||||
import com.artemchep.keyguard.common.service.filter.AddCipherFilter
|
||||
import com.artemchep.keyguard.common.service.filter.GetCipherFilters
|
||||
import com.artemchep.keyguard.common.service.filter.RemoveCipherFilterById
|
||||
import com.artemchep.keyguard.common.service.filter.RenameCipherFilter
|
||||
import com.artemchep.keyguard.common.service.filter.impl.AddCipherFilterImpl
|
||||
import com.artemchep.keyguard.common.service.filter.impl.GetCipherFiltersImpl
|
||||
import com.artemchep.keyguard.common.service.filter.impl.RemoveCipherFilterByIdImpl
|
||||
import com.artemchep.keyguard.common.service.filter.impl.RenameCipherFilterImpl
|
||||
import com.artemchep.keyguard.common.service.filter.repo.CipherFilterRepository
|
||||
import com.artemchep.keyguard.common.service.filter.repo.impl.CipherFilterRepositoryImpl
|
||||
import com.artemchep.keyguard.common.service.wordlist.repo.GeneratorWordlistRepository
|
||||
import com.artemchep.keyguard.common.service.wordlist.repo.impl.GeneratorWordlistRepositoryImpl
|
||||
import com.artemchep.keyguard.common.service.wordlist.repo.GeneratorWordlistWordRepository
|
||||
|
@ -483,6 +493,21 @@ fun DI.Builder.createSubDi2(
|
|||
bindSingleton<GeneratorWordlistWordRepository> {
|
||||
GeneratorWordlistWordRepositoryImpl(this)
|
||||
}
|
||||
bindSingleton<CipherFilterRepository> {
|
||||
CipherFilterRepositoryImpl(this)
|
||||
}
|
||||
bindSingleton<AddCipherFilter> {
|
||||
AddCipherFilterImpl(this)
|
||||
}
|
||||
bindSingleton<GetCipherFilters> {
|
||||
GetCipherFiltersImpl(this)
|
||||
}
|
||||
bindSingleton<RemoveCipherFilterById> {
|
||||
RemoveCipherFilterByIdImpl(this)
|
||||
}
|
||||
bindSingleton<RenameCipherFilter> {
|
||||
RenameCipherFilterImpl(this)
|
||||
}
|
||||
bindSingleton<UrlOverrideRepository> {
|
||||
UrlOverrideRepositoryImpl(this)
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import com.artemchep.keyguard.core.store.bitwarden.BitwardenOrganization
|
|||
import com.artemchep.keyguard.core.store.bitwarden.BitwardenProfile
|
||||
import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend
|
||||
import com.artemchep.keyguard.core.store.bitwarden.BitwardenToken
|
||||
import com.artemchep.keyguard.data.CipherFilter
|
||||
import com.artemchep.keyguard.data.CipherUsageHistory
|
||||
import com.artemchep.keyguard.data.Database
|
||||
import com.artemchep.keyguard.data.GeneratorWordlist
|
||||
|
@ -106,6 +107,10 @@ class DatabaseManagerImpl(
|
|||
Database(
|
||||
driver = driver,
|
||||
cipherUsageHistoryAdapter = CipherUsageHistory.Adapter(InstantToLongAdapter),
|
||||
cipherFilterAdapter = CipherFilter.Adapter(
|
||||
updatedAtAdapter = InstantToLongAdapter,
|
||||
createdAtAdapter = InstantToLongAdapter,
|
||||
),
|
||||
generatorHistoryAdapter = GeneratorHistory.Adapter(InstantToLongAdapter),
|
||||
generatorWordlistAdapter = GeneratorWordlist.Adapter(InstantToLongAdapter),
|
||||
generatorEmailRelayAdapter = GeneratorEmailRelay.Adapter(InstantToLongAdapter),
|
||||
|
|
|
@ -104,6 +104,7 @@ fun AttachmentsScreen(
|
|||
modifier = modifier,
|
||||
items = state.getOrNull()?.filter?.items.orEmpty(),
|
||||
onClear = state.getOrNull()?.filter?.onClear,
|
||||
onSave = state.getOrNull()?.filter?.onSave,
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
|
|
|
@ -14,6 +14,7 @@ data class AttachmentsState(
|
|||
data class Filter(
|
||||
val items: List<FilterItem> = emptyList(),
|
||||
val onClear: (() -> Unit)? = null,
|
||||
val onSave: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
data class Stats(
|
||||
|
|
|
@ -136,7 +136,7 @@ fun produceAttachmentsScreenState(
|
|||
) {
|
||||
val selectionHandle = selectionHandle("selection")
|
||||
|
||||
val filterResult = createFilter()
|
||||
val filterResult = createFilter(directDI)
|
||||
|
||||
val ciphersFlow = getCiphers()
|
||||
|
||||
|
@ -481,6 +481,7 @@ fun produceAttachmentsScreenState(
|
|||
filter = AttachmentsState.Filter(
|
||||
items = filterState.items,
|
||||
onClear = filterState.onClear,
|
||||
onSave = filterState.onSave,
|
||||
),
|
||||
stats = AttachmentsState.Stats(
|
||||
totalAttachments = 0,
|
||||
|
|
|
@ -127,6 +127,7 @@ fun ExportScreenSkeleton(
|
|||
modifier = modifier,
|
||||
items = items,
|
||||
onClear = null,
|
||||
onSave = null,
|
||||
)
|
||||
},
|
||||
) { modifier, tabletUi ->
|
||||
|
@ -185,6 +186,7 @@ fun ExportScreenOk(
|
|||
modifier = modifier,
|
||||
items = filter.items,
|
||||
onClear = filter.onClear,
|
||||
onSave = filter.onSave,
|
||||
)
|
||||
},
|
||||
) { modifier, tabletUi ->
|
||||
|
@ -211,14 +213,14 @@ private fun ExportScreenFilterList(
|
|||
modifier: Modifier = Modifier,
|
||||
items: List<FilterItem>,
|
||||
onClear: (() -> Unit)?,
|
||||
onSave: (() -> Unit)?,
|
||||
) {
|
||||
FilterScreen(
|
||||
modifier = modifier,
|
||||
count = null,
|
||||
items = items,
|
||||
onClear = onClear,
|
||||
actions = {
|
||||
},
|
||||
onSave = onSave,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -227,12 +229,14 @@ private fun ExportScreenFilterButton(
|
|||
modifier: Modifier = Modifier,
|
||||
items: List<FilterItem>,
|
||||
onClear: (() -> Unit)?,
|
||||
onSave: (() -> Unit)?,
|
||||
) {
|
||||
FilterButton(
|
||||
modifier = modifier,
|
||||
count = null,
|
||||
items = items,
|
||||
onClear = onClear,
|
||||
onSave = onSave,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -277,6 +281,7 @@ private fun ExportScreen(
|
|||
modifier = Modifier,
|
||||
items = filter.items,
|
||||
onClear = filter.onClear,
|
||||
onSave = filter.onSave,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
|
@ -26,6 +26,7 @@ data class ExportState(
|
|||
data class Filter(
|
||||
val items: List<FilterItem>,
|
||||
val onClear: (() -> Unit)? = null,
|
||||
val onSave: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
|
|
|
@ -131,7 +131,7 @@ fun produceExportScreenState(
|
|||
}
|
||||
.shareIn(screenScope, SharingStarted.WhileSubscribed(), replay = 1)
|
||||
|
||||
val filterResult = createFilter()
|
||||
val filterResult = createFilter(directDI)
|
||||
|
||||
val filteredCiphersFlow = ciphersFlow
|
||||
.map {
|
||||
|
@ -192,6 +192,7 @@ fun produceExportScreenState(
|
|||
ExportState.Filter(
|
||||
items = filterState.items,
|
||||
onClear = filterState.onClear,
|
||||
onSave = filterState.onSave,
|
||||
)
|
||||
}
|
||||
.stateIn(screenScope)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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() {
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -24,6 +24,7 @@ import com.artemchep.keyguard.feature.confirmation.ConfirmationRoute
|
|||
import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogIntent
|
||||
import com.artemchep.keyguard.feature.crashlytics.crashlyticsAttempt
|
||||
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
|
||||
import com.artemchep.keyguard.feature.home.vault.model.short
|
||||
import com.artemchep.keyguard.feature.navigation.NavigationIntent
|
||||
import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver
|
||||
import com.artemchep.keyguard.feature.navigation.state.produceScreenState
|
||||
|
@ -292,18 +293,7 @@ fun produceEmailRelayListState(
|
|||
)
|
||||
}
|
||||
}
|
||||
val icon = VaultItemIcon.TextIcon(
|
||||
run {
|
||||
val words = it.name.split(" ")
|
||||
if (words.size <= 1) {
|
||||
return@run words.firstOrNull()?.take(2).orEmpty()
|
||||
}
|
||||
|
||||
words
|
||||
.take(2)
|
||||
.joinToString("") { it.take(1) }
|
||||
}.uppercase(),
|
||||
)
|
||||
val icon = VaultItemIcon.TextIcon.short(it.name)
|
||||
|
||||
val selectableFlow = selectionHandle
|
||||
.idsFlow
|
||||
|
|
|
@ -293,9 +293,6 @@ private fun WordlistItem(
|
|||
val nextEntry = navigationNextEntryOrNull()
|
||||
val nextRoute = nextEntry?.route as? WordlistViewRoute
|
||||
|
||||
MaterialTheme.colorScheme.selectedContainer
|
||||
.takeIf { LocalHasDetailPane.current }
|
||||
?: Color.Unspecified
|
||||
val selected = nextRoute?.args?.wordlistId == item.wordlistId
|
||||
if (selected) {
|
||||
return@run MaterialTheme.colorScheme.selectedContainer
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package com.artemchep.keyguard.feature.generator.wordlist.list
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AddLink
|
||||
import androidx.compose.material.icons.outlined.AttachFile
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Edit
|
||||
|
@ -22,6 +21,7 @@ import com.artemchep.keyguard.feature.generator.wordlist.WordlistsRoute
|
|||
import com.artemchep.keyguard.feature.generator.wordlist.util.WordlistUtil
|
||||
import com.artemchep.keyguard.feature.generator.wordlist.view.WordlistViewRoute
|
||||
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
|
||||
import com.artemchep.keyguard.feature.home.vault.model.short
|
||||
import com.artemchep.keyguard.feature.navigation.NavigationIntent
|
||||
import com.artemchep.keyguard.feature.navigation.state.produceScreenState
|
||||
import com.artemchep.keyguard.res.Res
|
||||
|
@ -128,7 +128,7 @@ fun produceWordlistListState(
|
|||
this += FlatItemAction(
|
||||
icon = Icons.Outlined.Edit,
|
||||
title = translate(Res.strings.edit),
|
||||
onClick = WordlistUtil::onEdit
|
||||
onClick = WordlistUtil::onRename
|
||||
.partially1(this@produceScreenState)
|
||||
.partially1(editWordlist)
|
||||
.partially1(selectedItem),
|
||||
|
@ -166,18 +166,7 @@ fun produceWordlistListState(
|
|||
.map { list ->
|
||||
list
|
||||
.map {
|
||||
val icon = VaultItemIcon.TextIcon(
|
||||
run {
|
||||
val words = it.name.split(" ")
|
||||
if (words.size <= 1) {
|
||||
return@run words.firstOrNull()?.take(2).orEmpty()
|
||||
}
|
||||
|
||||
words
|
||||
.take(2)
|
||||
.joinToString("") { it.take(1) }
|
||||
}.uppercase(),
|
||||
)
|
||||
val icon = VaultItemIcon.TextIcon.short(it.name)
|
||||
|
||||
val selectableFlow = selectionHandle
|
||||
.idsFlow
|
||||
|
|
|
@ -18,12 +18,13 @@ import com.artemchep.keyguard.feature.navigation.NavigationIntent
|
|||
import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver
|
||||
import com.artemchep.keyguard.feature.navigation.state.RememberStateFlowScope
|
||||
import com.artemchep.keyguard.res.Res
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardCipherFilter
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardWordlist
|
||||
import com.artemchep.keyguard.ui.icons.icon
|
||||
|
||||
object WordlistUtil {
|
||||
context(RememberStateFlowScope)
|
||||
fun onEdit(
|
||||
fun onRename(
|
||||
editWordlist: EditWordlist,
|
||||
entity: DGeneratorWordlist,
|
||||
) {
|
||||
|
@ -43,7 +44,7 @@ object WordlistUtil {
|
|||
route = ConfirmationRoute(
|
||||
args = ConfirmationRoute.Args(
|
||||
icon = icon(
|
||||
main = Icons.Outlined.KeyguardWordlist,
|
||||
main = Icons.Outlined.KeyguardCipherFilter,
|
||||
secondary = Icons.Outlined.Edit,
|
||||
),
|
||||
title = translate(Res.strings.wordlist_edit_wordlist_title),
|
||||
|
|
|
@ -79,7 +79,7 @@ fun produceWordlistViewState(
|
|||
this += FlatItemAction(
|
||||
icon = Icons.Outlined.Edit,
|
||||
title = translate(Res.strings.edit),
|
||||
onClick = WordlistUtil::onEdit
|
||||
onClick = WordlistUtil::onRename
|
||||
.partially1(this@produceScreenState)
|
||||
.partially1(editWordlist)
|
||||
.partially1(wordlist),
|
||||
|
|
|
@ -26,6 +26,7 @@ import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogInten
|
|||
import com.artemchep.keyguard.feature.home.settings.accounts.model.AccountItem
|
||||
import com.artemchep.keyguard.feature.home.vault.VaultRoute
|
||||
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
|
||||
import com.artemchep.keyguard.feature.home.vault.model.short
|
||||
import com.artemchep.keyguard.feature.navigation.NavigationIntent
|
||||
import com.artemchep.keyguard.feature.navigation.state.produceScreenState
|
||||
import com.artemchep.keyguard.res.Res
|
||||
|
@ -235,18 +236,7 @@ fun accountListScreenState(
|
|||
val busy = syncing || removing
|
||||
val accent = profile?.accentColor
|
||||
?: generateAccentColorsByAccountId(it.id.id)
|
||||
val icon = VaultItemIcon.TextIcon(
|
||||
run {
|
||||
val words = profile?.name?.split(" ").orEmpty()
|
||||
if (words.size <= 1) {
|
||||
return@run words.firstOrNull()?.take(2).orEmpty()
|
||||
}
|
||||
|
||||
words
|
||||
.take(2)
|
||||
.joinToString("") { it.take(1) }
|
||||
}.uppercase(),
|
||||
)
|
||||
val icon = VaultItemIcon.TextIcon.short(profile?.name.orEmpty())
|
||||
AccountItem.Item(
|
||||
id = it.id.id,
|
||||
icon = icon,
|
||||
|
|
|
@ -778,7 +778,8 @@ fun BoxScope.VaultItemIcon2(
|
|||
.align(Alignment.Center),
|
||||
imageVector = icon.imageVector,
|
||||
contentDescription = null,
|
||||
tint = Color.Black,
|
||||
tint = Color.Black
|
||||
.combineAlpha(MediumEmphasisAlpha),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -14,8 +14,9 @@ sealed interface FilterItem : FilterItemModel {
|
|||
data class Section(
|
||||
override val sectionId: String,
|
||||
override val text: String,
|
||||
override val expandable: Boolean = true,
|
||||
override val expanded: Boolean = true,
|
||||
override val onClick: () -> Unit,
|
||||
override val onClick: (() -> Unit)?,
|
||||
) : FilterItem, FilterItemModel.Section {
|
||||
companion object;
|
||||
|
||||
|
@ -25,7 +26,12 @@ sealed interface FilterItem : FilterItemModel {
|
|||
data class Item(
|
||||
override val sectionId: String,
|
||||
val filterSectionId: String,
|
||||
val filters: Set<DFilter.Primitive>,
|
||||
val filter: Filter,
|
||||
/**
|
||||
* Unique identifier of the set of
|
||||
* filters.
|
||||
*/
|
||||
val filterId: String = filter.id,
|
||||
override val checked: Boolean,
|
||||
override val fill: Boolean,
|
||||
override val indent: Int = 0,
|
||||
|
@ -33,10 +39,25 @@ sealed interface FilterItem : FilterItemModel {
|
|||
override val title: String,
|
||||
override val text: String?,
|
||||
override val onClick: (() -> Unit)?,
|
||||
override val enabled: Boolean = onClick != null,
|
||||
) : FilterItem, FilterItemModel.Item {
|
||||
companion object;
|
||||
|
||||
override val id: String =
|
||||
sectionId + "|" + filterSectionId + "|" + filters.joinToString(separator = ",") { it.key }
|
||||
override val id: String = "$sectionId|$filterSectionId|$filterId"
|
||||
|
||||
sealed interface Filter {
|
||||
val id: String
|
||||
|
||||
data class Toggle(
|
||||
val filters: Set<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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@ package com.artemchep.keyguard.feature.home.vault.model
|
|||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import com.artemchep.keyguard.common.util.asCodePointsSequence
|
||||
import com.artemchep.keyguard.common.util.nextSymbol
|
||||
import com.artemchep.keyguard.feature.favicon.AppIconUrl
|
||||
import com.artemchep.keyguard.feature.favicon.FaviconUrl
|
||||
import dev.icerock.moko.resources.ImageResource
|
||||
|
@ -33,5 +35,30 @@ sealed interface VaultItemIcon {
|
|||
@Immutable
|
||||
data class TextIcon(
|
||||
val text: String,
|
||||
) : VaultItemIcon
|
||||
) : VaultItemIcon {
|
||||
companion object
|
||||
}
|
||||
}
|
||||
|
||||
fun VaultItemIcon.TextIcon.Companion.short(text: String): VaultItemIcon.TextIcon {
|
||||
val abbr = kotlin.run {
|
||||
val words = text.split(" ")
|
||||
if (words.size <= 1) {
|
||||
val word = words.firstOrNull()
|
||||
?: return@run ""
|
||||
return@run (0 until 2)
|
||||
.fold("") { str, _ ->
|
||||
str + word.nextSymbol(index = str.length)
|
||||
}
|
||||
}
|
||||
|
||||
words
|
||||
.take(2)
|
||||
.joinToString("") {
|
||||
it.nextSymbol()
|
||||
}
|
||||
}
|
||||
return VaultItemIcon.TextIcon(
|
||||
text = abbr,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -6,8 +6,10 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.CloudOff
|
||||
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||
import androidx.compose.material.icons.outlined.FilterAlt
|
||||
import androidx.compose.material.icons.outlined.FolderOff
|
||||
import androidx.compose.material.icons.outlined.Key
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
|
@ -18,6 +20,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||
import androidx.compose.ui.unit.dp
|
||||
import arrow.core.partially1
|
||||
import arrow.core.widen
|
||||
import com.artemchep.keyguard.common.io.launchIn
|
||||
import com.artemchep.keyguard.common.model.DAccount
|
||||
import com.artemchep.keyguard.common.model.DCollection
|
||||
import com.artemchep.keyguard.common.model.DFilter
|
||||
|
@ -27,22 +30,37 @@ import com.artemchep.keyguard.common.model.DProfile
|
|||
import com.artemchep.keyguard.common.model.DSecret
|
||||
import com.artemchep.keyguard.common.model.iconImageVector
|
||||
import com.artemchep.keyguard.common.model.titleH
|
||||
import com.artemchep.keyguard.common.service.filter.AddCipherFilter
|
||||
import com.artemchep.keyguard.common.service.filter.GetCipherFilters
|
||||
import com.artemchep.keyguard.common.service.filter.model.AddCipherFilterRequest
|
||||
import com.artemchep.keyguard.common.usecase.GetFolderTree
|
||||
import com.artemchep.keyguard.common.util.StringComparatorIgnoreCase
|
||||
import com.artemchep.keyguard.feature.confirmation.ConfirmationRoute
|
||||
import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogIntent
|
||||
import com.artemchep.keyguard.feature.home.vault.component.rememberSecretAccentColor
|
||||
import com.artemchep.keyguard.feature.home.vault.model.FilterItem
|
||||
import com.artemchep.keyguard.feature.home.vault.search.filter.FilterHolder
|
||||
import com.artemchep.keyguard.feature.localization.TextHolder
|
||||
import com.artemchep.keyguard.feature.navigation.state.PersistedStorage
|
||||
import com.artemchep.keyguard.feature.navigation.state.RememberStateFlowScope
|
||||
import com.artemchep.keyguard.feature.navigation.state.translate
|
||||
import com.artemchep.keyguard.res.Res
|
||||
import com.artemchep.keyguard.ui.icons.AccentColors
|
||||
import com.artemchep.keyguard.ui.icons.IconBox
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardAttachment
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardAuthReprompt
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardCipherFilter
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardFailedItems
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardIgnoredAlerts
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardPendingSyncItems
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardTwoFa
|
||||
import com.artemchep.keyguard.ui.icons.icon
|
||||
import com.artemchep.keyguard.ui.icons.iconSmall
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
@ -66,10 +84,54 @@ private fun <T, R> mapCiphers(
|
|||
data class CreateFilterResult(
|
||||
val filterFlow: Flow<FilterHolder>,
|
||||
val onToggle: (String, Set<DFilter.Primitive>) -> Unit,
|
||||
val onApply: (Map<String, Set<DFilter.Primitive>>) -> 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(
|
||||
state = mapOf(),
|
||||
)
|
||||
|
@ -86,6 +148,25 @@ suspend fun RememberStateFlowScope.createFilter(): CreateFilterResult {
|
|||
val onClear = {
|
||||
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> ->
|
||||
filterSink.update { holder ->
|
||||
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(
|
||||
filterFlow = filterSink,
|
||||
onToggle = onToggle,
|
||||
onApply = onApply,
|
||||
onClear = onClear,
|
||||
onSave = onSave,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -116,6 +212,7 @@ data class OurFilterResult(
|
|||
val rev: Int = 0,
|
||||
val items: List<FilterItem> = emptyList(),
|
||||
val onClear: (() -> Unit)? = null,
|
||||
val onSave: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
data class FilterParams(
|
||||
|
@ -158,6 +255,8 @@ suspend fun <
|
|||
input: CreateFilterResult,
|
||||
params: FilterParams = FilterParams(),
|
||||
): Flow<OurFilterResult> {
|
||||
val getCipherFilters: GetCipherFilters = directDI.instance()
|
||||
|
||||
val storage = kotlin.run {
|
||||
val disk = loadDiskHandle("ciphers.filter")
|
||||
PersistedStorage.InDisk(disk)
|
||||
|
@ -213,17 +312,12 @@ suspend fun <
|
|||
sectionId: String,
|
||||
sectionTitle: String,
|
||||
collapse: Boolean = true,
|
||||
checked: (FilterItem.Item, FilterHolder) -> Boolean,
|
||||
) = this
|
||||
.combine(input.filterFlow) { items, filterHolder ->
|
||||
items
|
||||
.map { item ->
|
||||
val shouldBeChecked = kotlin.run {
|
||||
val activeFilters = filterHolder.state[item.filterSectionId].orEmpty()
|
||||
item.filters
|
||||
.all { itemFilter ->
|
||||
itemFilter in activeFilters
|
||||
}
|
||||
}
|
||||
val shouldBeChecked = checked(item, filterHolder)
|
||||
if (shouldBeChecked == item.checked) {
|
||||
return@map item
|
||||
}
|
||||
|
@ -233,7 +327,7 @@ suspend fun <
|
|||
}
|
||||
.distinctUntilChanged()
|
||||
.map { items ->
|
||||
if (items.size <= 1 && collapse) {
|
||||
if (items.size <= 1 && collapse || items.isEmpty()) {
|
||||
// Do not show a single filter item.
|
||||
return@map emptyList<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"
|
||||
val typeSectionId = "type"
|
||||
val accountSectionId = "account"
|
||||
val folderSectionId = "folder"
|
||||
val collectionSectionId = "collection"
|
||||
val organizationSectionId = "organization"
|
||||
val miscSectionId = "misc"
|
||||
is FilterItem.Item.Filter.Apply -> kotlin.run {
|
||||
// If the size of the current state and the item
|
||||
// state is different then there's no way it's currently
|
||||
// selected.
|
||||
if (filterHolder.state.size != item.filter.filters.size) {
|
||||
return@run false
|
||||
}
|
||||
|
||||
item.filter.filters
|
||||
.all { (filterSectionId, filterSet) ->
|
||||
val activeFilters = filterHolder.state[filterSectionId].orEmpty()
|
||||
activeFilters == filterSet
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val setOfNull = setOf(null)
|
||||
|
||||
fun createFilterAction(
|
||||
sectionId: String,
|
||||
|
@ -276,7 +398,9 @@ suspend fun <
|
|||
) = FilterItem.Item(
|
||||
sectionId = sectionId,
|
||||
filterSectionId = filterSectionId,
|
||||
filter = FilterItem.Item.Filter.Toggle(
|
||||
filters = filter,
|
||||
),
|
||||
leading = when {
|
||||
icon != null -> {
|
||||
// composable
|
||||
|
@ -325,7 +449,7 @@ suspend fun <
|
|||
tint: AccentColors? = null,
|
||||
icon: ImageVector? = null,
|
||||
) = createFilterAction(
|
||||
sectionId = accountSectionId,
|
||||
sectionId = FilterSection.ACCOUNT.id,
|
||||
filter = accountIds
|
||||
.asSequence()
|
||||
.map { accountId ->
|
||||
|
@ -343,10 +467,10 @@ suspend fun <
|
|||
|
||||
fun createTypeFilterAction(
|
||||
type: DSecret.Type,
|
||||
sectionId: String = typeSectionId,
|
||||
sectionId: String = FilterSection.TYPE.id,
|
||||
) = createFilterAction(
|
||||
sectionId = sectionId,
|
||||
filterSectionId = typeSectionId,
|
||||
filterSectionId = FilterSection.TYPE.id,
|
||||
filter = setOf(
|
||||
DFilter.ByType(type),
|
||||
),
|
||||
|
@ -361,7 +485,7 @@ suspend fun <
|
|||
fill: Boolean,
|
||||
indent: Int,
|
||||
) = createFilterAction(
|
||||
sectionId = folderSectionId,
|
||||
sectionId = FilterSection.FOLDER.id,
|
||||
filter = folderIds
|
||||
.asSequence()
|
||||
.map { folderId ->
|
||||
|
@ -382,7 +506,7 @@ suspend fun <
|
|||
title: String,
|
||||
icon: ImageVector? = null,
|
||||
) = createFilterAction(
|
||||
sectionId = collectionSectionId,
|
||||
sectionId = FilterSection.COLLECTION.id,
|
||||
filter = collectionIds
|
||||
.asSequence()
|
||||
.map { collectionId ->
|
||||
|
@ -401,7 +525,7 @@ suspend fun <
|
|||
title: String,
|
||||
icon: ImageVector? = null,
|
||||
) = createFilterAction(
|
||||
sectionId = organizationSectionId,
|
||||
sectionId = FilterSection.ORGANIZATION.id,
|
||||
filter = organizationIds
|
||||
.asSequence()
|
||||
.map { organizationId ->
|
||||
|
@ -432,7 +556,8 @@ suspend fun <
|
|||
.combine(filterAccountsWithCiphers) { items, accountIds ->
|
||||
items
|
||||
.filter { filterItem ->
|
||||
filterItem.filters
|
||||
val filterItemFilter = filterItem.filter as FilterItem.Item.Filter.Toggle
|
||||
filterItemFilter.filters
|
||||
.any { filter ->
|
||||
val filterFixed = filter as DFilter.ById
|
||||
require(filterFixed.what == DFilter.ById.What.ACCOUNT)
|
||||
|
@ -441,8 +566,8 @@ suspend fun <
|
|||
}
|
||||
}
|
||||
.aaa(
|
||||
sectionId = accountSectionId,
|
||||
sectionTitle = translate(Res.strings.account),
|
||||
sectionId = FilterSection.ACCOUNT.id,
|
||||
sectionTitle = translate(FilterSection.ACCOUNT.title),
|
||||
)
|
||||
.filterSection(params.section.account)
|
||||
|
||||
|
@ -468,8 +593,8 @@ suspend fun <
|
|||
}
|
||||
}
|
||||
.aaa(
|
||||
sectionId = typeSectionId,
|
||||
sectionTitle = translate(Res.strings.type),
|
||||
sectionId = FilterSection.TYPE.id,
|
||||
sectionTitle = translate(FilterSection.TYPE.title),
|
||||
)
|
||||
.filterSection(params.section.type)
|
||||
|
||||
|
@ -531,7 +656,8 @@ suspend fun <
|
|||
.combine(filterFoldersWithCiphers) { items, folderIds ->
|
||||
items
|
||||
.filter { filterItem ->
|
||||
filterItem.filters
|
||||
val filterItemFilter = filterItem.filter as FilterItem.Item.Filter.Toggle
|
||||
filterItemFilter.filters
|
||||
.any { filter ->
|
||||
val filterFixed = filter as DFilter.ById
|
||||
require(filterFixed.what == DFilter.ById.What.FOLDER)
|
||||
|
@ -540,8 +666,8 @@ suspend fun <
|
|||
}
|
||||
}
|
||||
.aaa(
|
||||
sectionId = folderSectionId,
|
||||
sectionTitle = translate(Res.strings.folder),
|
||||
sectionId = FilterSection.FOLDER.id,
|
||||
sectionTitle = translate(FilterSection.FOLDER.title),
|
||||
)
|
||||
.filterSection(params.section.folder)
|
||||
|
||||
|
@ -573,7 +699,8 @@ suspend fun <
|
|||
.combine(filterCollectionsWithCiphers) { items, collectionIds ->
|
||||
items
|
||||
.filter { filterItem ->
|
||||
filterItem.filters
|
||||
val filterItemFilter = filterItem.filter as FilterItem.Item.Filter.Toggle
|
||||
filterItemFilter.filters
|
||||
.any { filter ->
|
||||
val filterFixed = filter as DFilter.ById
|
||||
require(filterFixed.what == DFilter.ById.What.COLLECTION)
|
||||
|
@ -582,8 +709,8 @@ suspend fun <
|
|||
}
|
||||
}
|
||||
.aaa(
|
||||
sectionId = collectionSectionId,
|
||||
sectionTitle = translate(Res.strings.collection),
|
||||
sectionId = FilterSection.COLLECTION.id,
|
||||
sectionTitle = translate(FilterSection.COLLECTION.title),
|
||||
)
|
||||
.filterSection(params.section.collection)
|
||||
|
||||
|
@ -615,7 +742,8 @@ suspend fun <
|
|||
.combine(filterOrganizationsWithCiphers) { items, organizationIds ->
|
||||
items
|
||||
.filter { filterItem ->
|
||||
filterItem.filters
|
||||
val filterItemFilter = filterItem.filter as FilterItem.Item.Filter.Toggle
|
||||
filterItemFilter.filters
|
||||
.any { filter ->
|
||||
val filterFixed = filter as DFilter.ById
|
||||
require(filterFixed.what == DFilter.ById.What.ORGANIZATION)
|
||||
|
@ -624,74 +752,74 @@ suspend fun <
|
|||
}
|
||||
}
|
||||
.aaa(
|
||||
sectionId = organizationSectionId,
|
||||
sectionTitle = translate(Res.strings.organization),
|
||||
sectionId = FilterSection.ORGANIZATION.id,
|
||||
sectionTitle = translate(FilterSection.ORGANIZATION.title),
|
||||
)
|
||||
.filterSection(params.section.organization)
|
||||
|
||||
val filterMiscAll = listOf(
|
||||
createFilterAction(
|
||||
sectionId = miscSectionId,
|
||||
sectionId = FilterSection.MISC.id,
|
||||
filter = setOf(
|
||||
DFilter.ByOtp,
|
||||
),
|
||||
filterSectionId = "$miscSectionId.otp",
|
||||
title = translate(Res.strings.one_time_password),
|
||||
icon = Icons.Outlined.KeyguardTwoFa,
|
||||
filterSectionId = "${FilterSection.MISC.id}.otp",
|
||||
title = translate(DFilter.ByOtp.content.title),
|
||||
icon = DFilter.ByOtp.content.icon,
|
||||
),
|
||||
createFilterAction(
|
||||
sectionId = miscSectionId,
|
||||
sectionId = FilterSection.MISC.id,
|
||||
filter = setOf(
|
||||
DFilter.ByAttachments,
|
||||
),
|
||||
filterSectionId = "$miscSectionId.attachments",
|
||||
title = translate(Res.strings.attachments),
|
||||
icon = Icons.Outlined.KeyguardAttachment,
|
||||
filterSectionId = "${FilterSection.MISC.id}.attachments",
|
||||
title = translate(DFilter.ByAttachments.content.title),
|
||||
icon = DFilter.ByAttachments.content.icon,
|
||||
),
|
||||
createFilterAction(
|
||||
sectionId = miscSectionId,
|
||||
sectionId = FilterSection.MISC.id,
|
||||
filter = setOf(
|
||||
DFilter.ByPasskeys,
|
||||
),
|
||||
filterSectionId = "$miscSectionId.passkeys",
|
||||
title = translate(Res.strings.passkeys),
|
||||
icon = Icons.Outlined.Key,
|
||||
filterSectionId = "${FilterSection.MISC.id}.passkeys",
|
||||
title = translate(DFilter.ByPasskeys.content.title),
|
||||
icon = DFilter.ByPasskeys.content.icon,
|
||||
),
|
||||
createFilterAction(
|
||||
sectionId = miscSectionId,
|
||||
sectionId = FilterSection.MISC.id,
|
||||
filter = setOf(
|
||||
DFilter.ByReprompt(reprompt = true),
|
||||
),
|
||||
filterSectionId = "$miscSectionId.reprompt",
|
||||
title = "Auth re-prompt",
|
||||
icon = Icons.Outlined.Lock,
|
||||
filterSectionId = "${FilterSection.MISC.id}.reprompt",
|
||||
title = translate(Res.strings.filter_auth_reprompt_items),
|
||||
icon = Icons.Outlined.KeyguardAuthReprompt,
|
||||
),
|
||||
createFilterAction(
|
||||
sectionId = miscSectionId,
|
||||
sectionId = FilterSection.MISC.id,
|
||||
filter = setOf(
|
||||
DFilter.BySync(synced = false),
|
||||
),
|
||||
filterSectionId = "$miscSectionId.sync",
|
||||
title = "Un-synced",
|
||||
icon = Icons.Outlined.CloudOff,
|
||||
filterSectionId = "${FilterSection.MISC.id}.sync",
|
||||
title = translate(Res.strings.filter_pending_items),
|
||||
icon = Icons.Outlined.KeyguardPendingSyncItems,
|
||||
),
|
||||
createFilterAction(
|
||||
sectionId = miscSectionId,
|
||||
sectionId = FilterSection.MISC.id,
|
||||
filter = setOf(
|
||||
DFilter.ByError(error = true),
|
||||
),
|
||||
filterSectionId = "$miscSectionId.error",
|
||||
title = "Failed",
|
||||
icon = Icons.Outlined.ErrorOutline,
|
||||
filterSectionId = "${FilterSection.MISC.id}.error",
|
||||
title = translate(Res.strings.filter_failed_items),
|
||||
icon = Icons.Outlined.KeyguardFailedItems,
|
||||
),
|
||||
createFilterAction(
|
||||
sectionId = miscSectionId,
|
||||
sectionId = FilterSection.MISC.id,
|
||||
filter = setOf(
|
||||
DFilter.ByIgnoredAlerts,
|
||||
),
|
||||
filterSectionId = "$miscSectionId.watchtower_alerts",
|
||||
filterSectionId = "${FilterSection.MISC.id}.watchtower_alerts",
|
||||
title = translate(Res.strings.ignored_alerts),
|
||||
icon = Icons.Outlined.NotificationsOff,
|
||||
icon = Icons.Outlined.KeyguardIgnoredAlerts,
|
||||
),
|
||||
)
|
||||
|
||||
|
@ -700,59 +828,51 @@ suspend fun <
|
|||
filterMiscAll
|
||||
}
|
||||
.aaa(
|
||||
sectionId = miscSectionId,
|
||||
sectionTitle = translate(Res.strings.misc),
|
||||
sectionId = FilterSection.MISC.id,
|
||||
sectionTitle = translate(FilterSection.MISC.title),
|
||||
collapse = false,
|
||||
)
|
||||
.filterSection(params.section.misc)
|
||||
|
||||
val filterCustomTypesAll = listOf(
|
||||
DSecret.Type.Login to createTypeFilterAction(
|
||||
sectionId = customSectionId,
|
||||
type = DSecret.Type.Login,
|
||||
),
|
||||
DSecret.Type.Card to createTypeFilterAction(
|
||||
sectionId = customSectionId,
|
||||
type = DSecret.Type.Card,
|
||||
),
|
||||
DSecret.Type.Identity to createTypeFilterAction(
|
||||
sectionId = customSectionId,
|
||||
type = DSecret.Type.Identity,
|
||||
),
|
||||
DSecret.Type.SecureNote to createTypeFilterAction(
|
||||
sectionId = customSectionId,
|
||||
type = DSecret.Type.SecureNote,
|
||||
),
|
||||
)
|
||||
|
||||
val filterCustomAll = listOf(
|
||||
createFilterAction(
|
||||
sectionId = customSectionId,
|
||||
filter = setOf(
|
||||
DFilter.ByOtp,
|
||||
),
|
||||
filterSectionId = "$miscSectionId.otp",
|
||||
title = translate(Res.strings.one_time_password),
|
||||
icon = Icons.Outlined.KeyguardTwoFa,
|
||||
),
|
||||
)
|
||||
|
||||
if (params.deeplinkCustomFilter == "2fa") {
|
||||
input.onToggle(
|
||||
"$miscSectionId.otp",
|
||||
setOf(
|
||||
DFilter.ByOtp,
|
||||
),
|
||||
)
|
||||
if (params.deeplinkCustomFilter != null) {
|
||||
val customFilter = kotlin.run {
|
||||
val customFilters = getCipherFilters()
|
||||
.first()
|
||||
customFilters.firstOrNull { it.id == params.deeplinkCustomFilter }
|
||||
}
|
||||
if (customFilter != null) {
|
||||
input.onApply(customFilter.filter)
|
||||
}
|
||||
}
|
||||
|
||||
val filterCustomListFlow = flowOf(Unit)
|
||||
.map {
|
||||
filterCustomTypesAll.map { it.second } + filterCustomAll
|
||||
val filterCustomListFlow = getCipherFilters()
|
||||
.map { filters ->
|
||||
filters
|
||||
.map { filter ->
|
||||
FilterItem.Item(
|
||||
sectionId = FilterSection.CUSTOM.id,
|
||||
filterSectionId = FilterSection.CUSTOM.id,
|
||||
filter = FilterItem.Item.Filter.Apply(
|
||||
filters = filter.filter,
|
||||
id = filter.id,
|
||||
),
|
||||
leading = filter.icon
|
||||
?.let {
|
||||
iconSmall(it)
|
||||
},
|
||||
title = filter.name,
|
||||
text = null,
|
||||
onClick = input.onApply
|
||||
.partially1(filter.filter),
|
||||
fill = false,
|
||||
indent = 0,
|
||||
checked = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
.aaa(
|
||||
sectionId = customSectionId,
|
||||
sectionTitle = translate(Res.strings.custom),
|
||||
sectionId = FilterSection.CUSTOM.id,
|
||||
sectionTitle = translate(FilterSection.CUSTOM.title),
|
||||
collapse = false,
|
||||
)
|
||||
.filterSection(params.section.custom)
|
||||
|
@ -795,7 +915,7 @@ suspend fun <
|
|||
when (it) {
|
||||
is FilterItem.Section -> null
|
||||
is FilterItem.Item -> it.takeIf { it.checked }
|
||||
?.sectionId
|
||||
?.filterSectionId
|
||||
}
|
||||
}
|
||||
.toSet()
|
||||
|
@ -808,9 +928,11 @@ suspend fun <
|
|||
val fastEnabled = item.checked ||
|
||||
// If one of the items in a section is enabled, then
|
||||
// enable the whole section.
|
||||
item.sectionId in checkedSectionIds
|
||||
item.filterSectionId in checkedSectionIds
|
||||
val enabled = fastEnabled || kotlin.run {
|
||||
item.filters
|
||||
val filterItemFilter = item.filter as? FilterItem.Item.Filter.Toggle
|
||||
?: return@run true
|
||||
filterItemFilter.filters
|
||||
.any { filter ->
|
||||
val filterPredicate = filter.prepare(directDI, outputCiphers)
|
||||
outputCiphers.any(filterPredicate)
|
||||
|
@ -822,7 +944,10 @@ suspend fun <
|
|||
return@forEach
|
||||
}
|
||||
|
||||
out += item.copy(onClick = null)
|
||||
out += item.copy(
|
||||
onClick = null,
|
||||
enabled = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -833,6 +958,9 @@ suspend fun <
|
|||
rev = b.id,
|
||||
items = a,
|
||||
onClear = input.onClear.takeIf { b.id != 0 },
|
||||
onSave = input.onSave
|
||||
.takeIf { b.id != 0 }
|
||||
?.partially1(b.state),
|
||||
)
|
||||
}
|
||||
.flowOn(Dispatchers.Default)
|
||||
|
|
|
@ -22,6 +22,7 @@ import com.artemchep.keyguard.feature.home.vault.component.VaultViewTotpBadge
|
|||
import com.artemchep.keyguard.feature.home.vault.component.obscureCardNumber
|
||||
import com.artemchep.keyguard.feature.home.vault.model.VaultItem2
|
||||
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
|
||||
import com.artemchep.keyguard.feature.home.vault.model.short
|
||||
import com.artemchep.keyguard.feature.navigation.state.TranslatorScope
|
||||
import com.artemchep.keyguard.res.Res
|
||||
import com.artemchep.keyguard.ui.FlatItemAction
|
||||
|
@ -166,9 +167,7 @@ fun DSecret.toVaultItemIcon(
|
|||
}
|
||||
}
|
||||
val textIcon = if (name.isNotBlank()) {
|
||||
VaultItemIcon.TextIcon(
|
||||
text = name.take(2),
|
||||
)
|
||||
VaultItemIcon.TextIcon.short(name)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
|
@ -223,19 +223,13 @@ private fun VaultListFilterScreen(
|
|||
val count = (state.content as? VaultListState.Content.Items)?.count
|
||||
val filters = state.filters
|
||||
val clearFilters = state.clearFilters
|
||||
val saveFilters = state.saveFilters
|
||||
FilterScreen(
|
||||
modifier = modifier,
|
||||
count = count,
|
||||
items = filters,
|
||||
onClear = clearFilters,
|
||||
actions = {
|
||||
VaultListSortButton(
|
||||
state = state,
|
||||
)
|
||||
OptionsButton(
|
||||
actions = state.actions,
|
||||
)
|
||||
},
|
||||
onSave = saveFilters,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -247,11 +241,13 @@ private fun VaultListFilterButton(
|
|||
val count = (state.content as? VaultListState.Content.Items)?.count
|
||||
val filters = state.filters
|
||||
val clearFilters = state.clearFilters
|
||||
val saveFilters = state.saveFilters
|
||||
FilterButton(
|
||||
modifier = modifier,
|
||||
count = count,
|
||||
items = filters,
|
||||
onClear = clearFilters,
|
||||
onSave = saveFilters,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ data class VaultListState(
|
|||
val query: TextFieldModel2 = TextFieldModel2(mutableStateOf("")),
|
||||
val filters: List<FilterItem> = emptyList(),
|
||||
val sort: List<SortItem> = emptyList(),
|
||||
val saveFilters: (() -> Unit)? = null,
|
||||
val clearFilters: (() -> Unit)? = null,
|
||||
val clearSort: (() -> Unit)? = null,
|
||||
val selectCipher: ((DSecret) -> Unit)? = null,
|
||||
|
|
|
@ -76,6 +76,7 @@ import com.artemchep.keyguard.feature.decorator.ItemDecoratorDate
|
|||
import com.artemchep.keyguard.feature.decorator.ItemDecoratorNone
|
||||
import com.artemchep.keyguard.feature.decorator.ItemDecoratorTitle
|
||||
import com.artemchep.keyguard.feature.duplicates.list.createCipherSelectionFlow
|
||||
import com.artemchep.keyguard.feature.filter.CipherFiltersRoute
|
||||
import com.artemchep.keyguard.feature.generator.history.mapLatestScoped
|
||||
import com.artemchep.keyguard.feature.home.vault.VaultRoute
|
||||
import com.artemchep.keyguard.feature.home.vault.add.AddRoute
|
||||
|
@ -387,7 +388,7 @@ fun vaultListScreenState(
|
|||
var scrollPositionKey: Any? = null
|
||||
val scrollPositionSink = mutablePersistedFlow<OhOhOh>("scroll_state") { OhOhOh() }
|
||||
|
||||
val filterResult = createFilter()
|
||||
val filterResult = createFilter(directDI)
|
||||
val actionsFlow = kotlin.run {
|
||||
val actionTrashItem = FlatItemAction(
|
||||
leading = {
|
||||
|
@ -429,9 +430,15 @@ fun vaultListScreenState(
|
|||
},
|
||||
)
|
||||
val actionDownloadsFlow = flowOf(actionDownloadsItem)
|
||||
val actionFiltersItem = CipherFiltersRoute.actionOrNull(
|
||||
translator = this,
|
||||
navigate = ::navigate,
|
||||
)
|
||||
val actionFiltersFlow = flowOf(actionFiltersItem)
|
||||
val actionGroupFlow = combine(
|
||||
actionTrashFlow,
|
||||
actionDownloadsFlow,
|
||||
actionFiltersFlow,
|
||||
) { array ->
|
||||
buildContextItems {
|
||||
section {
|
||||
|
@ -1223,15 +1230,15 @@ fun vaultListScreenState(
|
|||
.map {
|
||||
val keepOtp = it.filterConfig?.filter
|
||||
?.let {
|
||||
DFilter.findOne(it, DFilter.ByOtp::class.java)
|
||||
DFilter.findAny<DFilter.ByOtp>(it)
|
||||
} != null
|
||||
val keepAttachment = it.filterConfig?.filter
|
||||
?.let {
|
||||
DFilter.findOne(it, DFilter.ByAttachments::class.java)
|
||||
DFilter.findAny<DFilter.ByAttachments>(it)
|
||||
} != null
|
||||
val keepPasskey = it.filterConfig?.filter
|
||||
?.let {
|
||||
DFilter.findOne(it, DFilter.ByPasskeys::class.java)
|
||||
DFilter.findAny<DFilter.ByPasskeys>(it)
|
||||
} != null ||
|
||||
// If a user is in the pick a passkey mode,
|
||||
// then we want to always show it in the items.
|
||||
|
@ -1511,6 +1518,7 @@ fun vaultListScreenState(
|
|||
} else {
|
||||
emptyList()
|
||||
},
|
||||
saveFilters = filters.onSave,
|
||||
clearFilters = filters.onClear,
|
||||
clearSort = if (sortDefault != sort) {
|
||||
{
|
||||
|
|
|
@ -9,6 +9,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Clear
|
||||
import androidx.compose.material.icons.outlined.Save
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
|
@ -18,6 +21,7 @@ import androidx.compose.runtime.SideEffect
|
|||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
@ -34,6 +38,7 @@ fun <T> DropdownButton(
|
|||
title: String,
|
||||
items: List<T>,
|
||||
onClear: (() -> Unit)?,
|
||||
onSave: (() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
|
@ -44,6 +49,18 @@ fun <T> DropdownButton(
|
|||
isExpanded = false
|
||||
}
|
||||
}
|
||||
val onExpandRequest = remember {
|
||||
// lambda
|
||||
{
|
||||
isExpanded = true
|
||||
}
|
||||
}
|
||||
val onDismissRequest = remember {
|
||||
// lambda
|
||||
{
|
||||
isExpanded = false
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
|
@ -55,9 +72,7 @@ fun <T> DropdownButton(
|
|||
IconButton(
|
||||
modifier = Modifier,
|
||||
enabled = items.isNotEmpty(),
|
||||
onClick = {
|
||||
isExpanded = !isExpanded
|
||||
},
|
||||
onClick = onExpandRequest,
|
||||
) {
|
||||
Box {
|
||||
Icon(icon, null)
|
||||
|
@ -82,9 +97,6 @@ fun <T> DropdownButton(
|
|||
|
||||
// Inject the dropdown popup to the bottom of the
|
||||
// content.
|
||||
val onDismissRequest = {
|
||||
isExpanded = false
|
||||
}
|
||||
DropdownMenu(
|
||||
modifier = Modifier
|
||||
.widthIn(
|
||||
|
@ -99,7 +111,43 @@ fun <T> DropdownButton(
|
|||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
title = title,
|
||||
onClear = onClear,
|
||||
actions = {
|
||||
val updatedOnSave by rememberUpdatedState(onSave)
|
||||
ExpandedIfNotEmptyForRow(
|
||||
valueOrNull = onSave,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
// At first we hide the popup
|
||||
// menu, because it launches a
|
||||
// new route.
|
||||
onDismissRequest()
|
||||
// Launch the screen.
|
||||
updatedOnSave?.invoke()
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Save,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
val updatedOnClear by rememberUpdatedState(onClear)
|
||||
ExpandedIfNotEmptyForRow(
|
||||
valueOrNull = onClear,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
updatedOnClear?.invoke()
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Clear,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
content()
|
||||
|
|
|
@ -1,32 +1,24 @@
|
|||
package com.artemchep.keyguard.feature.search.component
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Clear
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.artemchep.keyguard.res.Res
|
||||
import com.artemchep.keyguard.ui.ExpandedIfNotEmptyForRow
|
||||
import com.artemchep.keyguard.ui.theme.Dimens
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
|
||||
@Composable
|
||||
fun DropdownHeader(
|
||||
title: String,
|
||||
onClear: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
actions: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
|
@ -40,29 +32,11 @@ fun DropdownHeader(
|
|||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
val updatedOnClear by rememberUpdatedState(onClear)
|
||||
ExpandedIfNotEmptyForRow(
|
||||
valueOrNull = onClear,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
updatedOnClear?.invoke()
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Clear,
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.width(Dimens.buttonIconPadding),
|
||||
.width(8.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.strings.reset),
|
||||
)
|
||||
}
|
||||
}
|
||||
actions()
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.width(8.dp),
|
||||
|
|
|
@ -16,6 +16,7 @@ fun FilterButton(
|
|||
count: Int?,
|
||||
items: List<FilterItemModel>,
|
||||
onClear: (() -> Unit)?,
|
||||
onSave: (() -> Unit)?,
|
||||
) {
|
||||
DropdownButton(
|
||||
modifier = modifier,
|
||||
|
@ -23,6 +24,7 @@ fun FilterButton(
|
|||
title = stringResource(Res.strings.filter_header_title),
|
||||
items = items,
|
||||
onClear = onClear,
|
||||
onSave = onSave,
|
||||
) {
|
||||
VaultHomeScreenFilterPaneNumberOfItems(
|
||||
count = count,
|
||||
|
|
|
@ -67,6 +67,7 @@ private fun FilterItemItem(
|
|||
},
|
||||
),
|
||||
checked = item.checked,
|
||||
enabled = item.enabled,
|
||||
leading = item.leading,
|
||||
title = item.title,
|
||||
text = item.text,
|
||||
|
@ -79,6 +80,7 @@ private fun FilterItemSection(
|
|||
item: FilterItemModel.Section,
|
||||
) {
|
||||
FilterSectionComposable(
|
||||
expandable = item.expandable,
|
||||
expanded = item.expanded,
|
||||
title = item.text,
|
||||
onClick = item.onClick,
|
||||
|
|
|
@ -1,23 +1,28 @@
|
|||
package com.artemchep.keyguard.feature.search.filter
|
||||
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Clear
|
||||
import androidx.compose.material.icons.outlined.Save
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.artemchep.keyguard.feature.search.filter.component.VaultHomeScreenFilterPaneNumberOfItems
|
||||
import com.artemchep.keyguard.feature.search.filter.model.FilterItemModel
|
||||
import com.artemchep.keyguard.res.Res
|
||||
import com.artemchep.keyguard.ui.DefaultFab
|
||||
import com.artemchep.keyguard.ui.FabState
|
||||
import com.artemchep.keyguard.ui.ScaffoldColumn
|
||||
import com.artemchep.keyguard.ui.SmallFab
|
||||
import com.artemchep.keyguard.ui.icons.IconBox
|
||||
import com.artemchep.keyguard.ui.toolbar.SmallToolbar
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
|
||||
@Composable
|
||||
|
@ -26,25 +31,13 @@ fun FilterScreen(
|
|||
count: Int?,
|
||||
items: List<FilterItemModel>,
|
||||
onClear: (() -> Unit)?,
|
||||
actions: @Composable RowScope.() -> Unit,
|
||||
onSave: (() -> Unit)? = null,
|
||||
) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
ScaffoldColumn(
|
||||
modifier = modifier
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topAppBarScrollBehavior = scrollBehavior,
|
||||
// topBar = {
|
||||
// SmallToolbar(
|
||||
// title = {
|
||||
// Text(
|
||||
// text = stringResource(Res.strings.filter_header_title),
|
||||
// style = MaterialTheme.typography.titleMedium,
|
||||
// )
|
||||
// },
|
||||
// actions = actions,
|
||||
// scrollBehavior = scrollBehavior,
|
||||
// )
|
||||
// },
|
||||
floatingActionState = run {
|
||||
val fabState = if (onClear != null) {
|
||||
FabState(
|
||||
|
@ -57,6 +50,19 @@ fun FilterScreen(
|
|||
rememberUpdatedState(newValue = fabState)
|
||||
},
|
||||
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(
|
||||
icon = {
|
||||
IconBox(main = Icons.Outlined.Clear)
|
||||
|
@ -68,6 +74,7 @@ fun FilterScreen(
|
|||
},
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
VaultHomeScreenFilterPaneNumberOfItems(
|
||||
|
|
|
@ -42,6 +42,7 @@ fun FilterItemComposable(
|
|||
title: String,
|
||||
text: String?,
|
||||
onClick: (() -> Unit)?,
|
||||
enabled: Boolean = onClick != null,
|
||||
) {
|
||||
FilterItemLayout(
|
||||
modifier = modifier,
|
||||
|
@ -67,6 +68,7 @@ fun FilterItemComposable(
|
|||
}
|
||||
},
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ import com.artemchep.keyguard.ui.theme.combineAlpha
|
|||
@Composable
|
||||
fun FilterSectionComposable(
|
||||
modifier: Modifier = Modifier,
|
||||
expandable: Boolean,
|
||||
expanded: Boolean,
|
||||
title: String,
|
||||
onClick: (() -> Unit)?,
|
||||
|
@ -33,6 +34,10 @@ fun FilterSectionComposable(
|
|||
vertical = 0.dp,
|
||||
),
|
||||
trailing = {
|
||||
if (!expandable) {
|
||||
return@FlatItem
|
||||
}
|
||||
|
||||
val targetRotationX =
|
||||
if (expanded) {
|
||||
0f
|
||||
|
@ -63,5 +68,6 @@ fun FilterSectionComposable(
|
|||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
enabled = true,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -7,8 +7,9 @@ interface FilterItemModel {
|
|||
|
||||
interface Section : FilterItemModel {
|
||||
val text: String
|
||||
val expandable: Boolean
|
||||
val expanded: Boolean
|
||||
val onClick: () -> Unit
|
||||
val onClick: (() -> Unit)?
|
||||
}
|
||||
|
||||
interface Item : FilterItemModel {
|
||||
|
@ -16,6 +17,7 @@ interface FilterItemModel {
|
|||
val title: String
|
||||
val text: String?
|
||||
val checked: Boolean
|
||||
val enabled: Boolean
|
||||
val fill: Boolean
|
||||
val indent: Int
|
||||
val onClick: (() -> Unit)?
|
||||
|
|
|
@ -19,6 +19,7 @@ data class SendListState(
|
|||
val query: TextFieldModel2 = TextFieldModel2(mutableStateOf("")),
|
||||
val filters: List<SendFilterItem> = emptyList(),
|
||||
val sort: List<SendSortItem> = emptyList(),
|
||||
val saveFilters: (() -> Unit)? = null,
|
||||
val clearFilters: (() -> Unit)? = null,
|
||||
val clearSort: (() -> Unit)? = null,
|
||||
val showKeyboard: Boolean = false,
|
||||
|
|
|
@ -843,6 +843,7 @@ fun sendListScreenState(
|
|||
.takeIf { queryTrimmed.isEmpty() }
|
||||
.orEmpty(),
|
||||
primaryActions = primaryActions,
|
||||
saveFilters = null,
|
||||
clearFilters = filters.onClear,
|
||||
clearSort = if (sortDefault != sort) {
|
||||
{
|
||||
|
|
|
@ -346,14 +346,6 @@ private fun SendListFilterScreen(
|
|||
count = count,
|
||||
items = filters,
|
||||
onClear = clearFilters,
|
||||
actions = {
|
||||
SendListSortButton(
|
||||
state = state,
|
||||
)
|
||||
OptionsButton(
|
||||
actions = state.actions,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -365,11 +357,13 @@ private fun SendListFilterButton(
|
|||
val count = (state.content as? SendListState.Content.Items)?.count
|
||||
val filters = state.filters
|
||||
val clearFilters = state.clearFilters
|
||||
val saveFilters = state.saveFilters
|
||||
FilterButton(
|
||||
modifier = modifier,
|
||||
count = count,
|
||||
items = filters,
|
||||
onClear = clearFilters,
|
||||
onSave = saveFilters,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -14,8 +14,9 @@ sealed interface SendFilterItem : FilterItemModel {
|
|||
data class Section(
|
||||
override val sectionId: String,
|
||||
override val text: String,
|
||||
override val expandable: Boolean = true,
|
||||
override val expanded: Boolean = true,
|
||||
override val onClick: () -> Unit,
|
||||
override val onClick: (() -> Unit)?,
|
||||
) : SendFilterItem, FilterItemModel.Section {
|
||||
companion object;
|
||||
|
||||
|
@ -33,6 +34,7 @@ sealed interface SendFilterItem : FilterItemModel {
|
|||
override val title: String,
|
||||
override val text: String?,
|
||||
override val onClick: (() -> Unit)?,
|
||||
override val enabled: Boolean = onClick != null,
|
||||
) : SendFilterItem, FilterItemModel.Item {
|
||||
companion object;
|
||||
|
||||
|
|
|
@ -24,6 +24,7 @@ import com.artemchep.keyguard.feature.confirmation.ConfirmationRoute
|
|||
import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogIntent
|
||||
import com.artemchep.keyguard.feature.crashlytics.crashlyticsAttempt
|
||||
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
|
||||
import com.artemchep.keyguard.feature.home.vault.model.short
|
||||
import com.artemchep.keyguard.feature.navigation.NavigationIntent
|
||||
import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver
|
||||
import com.artemchep.keyguard.feature.navigation.state.produceScreenState
|
||||
|
@ -285,18 +286,7 @@ fun produceUrlOverrideListState(
|
|||
)
|
||||
}
|
||||
}
|
||||
val icon = VaultItemIcon.TextIcon(
|
||||
run {
|
||||
val words = it.name.split(" ")
|
||||
if (words.size <= 1) {
|
||||
return@run words.firstOrNull()?.take(2).orEmpty()
|
||||
}
|
||||
|
||||
words
|
||||
.take(2)
|
||||
.joinToString("") { it.take(1) }
|
||||
}.uppercase(),
|
||||
)
|
||||
val icon = VaultItemIcon.TextIcon.short(it.name)
|
||||
|
||||
val selectableFlow = selectionHandle
|
||||
.idsFlow
|
||||
|
|
|
@ -33,12 +33,10 @@ import androidx.compose.material.icons.Icons
|
|||
import androidx.compose.material.icons.outlined.AccountTree
|
||||
import androidx.compose.material.icons.outlined.CheckCircleOutline
|
||||
import androidx.compose.material.icons.outlined.CopyAll
|
||||
import androidx.compose.material.icons.outlined.DataArray
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||
import androidx.compose.material.icons.outlined.FolderOff
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material.icons.outlined.Recycling
|
||||
import androidx.compose.material.icons.outlined.ShortText
|
||||
import androidx.compose.material.icons.outlined.Timer
|
||||
import androidx.compose.material.icons.outlined.Warning
|
||||
|
@ -99,7 +97,17 @@ import com.artemchep.keyguard.ui.GridLayout
|
|||
import com.artemchep.keyguard.ui.OptionsButton
|
||||
import com.artemchep.keyguard.ui.animatedNumberText
|
||||
import com.artemchep.keyguard.ui.grid.preferredGridWidth
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardDuplicateItems
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardDuplicateWebsites
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardExpiringItems
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardIncompleteItems
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardPasskey
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardPwnedPassword
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardReusedPassword
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardTwoFa
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardPwnedWebsites
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardTrashedItems
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardUnsecureWebsites
|
||||
import com.artemchep.keyguard.ui.icons.KeyguardWebsite
|
||||
import com.artemchep.keyguard.ui.poweredby.PoweredBy2factorauth
|
||||
import com.artemchep.keyguard.ui.poweredby.PoweredByHaveibeenpwned
|
||||
|
@ -211,8 +219,6 @@ fun VaultHomeScreenFilterPaneCard2(
|
|||
count = null,
|
||||
items = items,
|
||||
onClear = onClear,
|
||||
actions = {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -397,6 +403,7 @@ private fun VaultHomeScreenFilterButton(
|
|||
modifier = modifier,
|
||||
items = state.filter.items,
|
||||
onClear = state.filter.onClear,
|
||||
onSave = state.filter.onSave,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -405,12 +412,14 @@ fun VaultHomeScreenFilterButton2(
|
|||
modifier: Modifier = Modifier,
|
||||
items: List<FilterItem>,
|
||||
onClear: (() -> Unit)?,
|
||||
onSave: (() -> Unit)?,
|
||||
) {
|
||||
FilterButton(
|
||||
modifier = modifier,
|
||||
count = null,
|
||||
items = items,
|
||||
onClear = onClear,
|
||||
onSave = onSave,
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -701,7 +710,7 @@ private fun CardPwnedPassword(
|
|||
)
|
||||
},
|
||||
text = stringResource(Res.strings.watchtower_item_pwned_passwords_text),
|
||||
imageVector = Icons.Outlined.DataArray,
|
||||
imageVector = Icons.Outlined.KeyguardPwnedPassword,
|
||||
onClick = state.onClick,
|
||||
content = {
|
||||
PoweredByHaveibeenpwned(
|
||||
|
@ -727,7 +736,7 @@ private fun CardReusedPassword(
|
|||
)
|
||||
},
|
||||
text = stringResource(Res.strings.watchtower_item_reused_passwords_text),
|
||||
imageVector = Icons.Outlined.Recycling,
|
||||
imageVector = Icons.Outlined.KeyguardReusedPassword,
|
||||
onClick = state.onClick,
|
||||
)
|
||||
}
|
||||
|
@ -746,7 +755,7 @@ private fun CardVulnerableAccounts(
|
|||
)
|
||||
},
|
||||
text = stringResource(Res.strings.watchtower_item_vulnerable_accounts_text),
|
||||
imageVector = Icons.Outlined.KeyguardWebsite,
|
||||
imageVector = Icons.Outlined.KeyguardPwnedWebsites,
|
||||
onClick = state.onClick,
|
||||
content = {
|
||||
PoweredByHaveibeenpwned(
|
||||
|
@ -772,7 +781,7 @@ private fun CardUnsecureWebsites(
|
|||
)
|
||||
},
|
||||
text = stringResource(Res.strings.watchtower_item_unsecure_websites_text),
|
||||
imageVector = Icons.Outlined.KeyguardWebsite,
|
||||
imageVector = Icons.Outlined.KeyguardUnsecureWebsites,
|
||||
onClick = state.onClick,
|
||||
)
|
||||
}
|
||||
|
@ -843,7 +852,7 @@ private fun CardInactivePasskey(
|
|||
)
|
||||
},
|
||||
text = stringResource(Res.strings.watchtower_item_inactive_passkey_text),
|
||||
imageVector = Icons.Outlined.KeyguardTwoFa,
|
||||
imageVector = Icons.Outlined.KeyguardPasskey,
|
||||
onClick = state.onClick,
|
||||
content = {
|
||||
PoweredByPasskeys(
|
||||
|
@ -869,7 +878,7 @@ private fun CardIncompleteItems(
|
|||
)
|
||||
},
|
||||
text = stringResource(Res.strings.watchtower_item_incomplete_items_text),
|
||||
imageVector = Icons.Outlined.ShortText,
|
||||
imageVector = Icons.Outlined.KeyguardIncompleteItems,
|
||||
onClick = state.onClick,
|
||||
)
|
||||
}
|
||||
|
@ -888,7 +897,7 @@ private fun CardExpiringItems(
|
|||
)
|
||||
},
|
||||
text = stringResource(Res.strings.watchtower_item_expiring_items_text),
|
||||
imageVector = Icons.Outlined.Timer,
|
||||
imageVector = Icons.Outlined.KeyguardExpiringItems,
|
||||
onClick = state.onClick,
|
||||
)
|
||||
}
|
||||
|
@ -907,7 +916,7 @@ private fun CardDuplicateWebsites(
|
|||
)
|
||||
},
|
||||
text = stringResource(Res.strings.watchtower_item_duplicate_websites_text),
|
||||
imageVector = Icons.Outlined.KeyguardWebsite,
|
||||
imageVector = Icons.Outlined.KeyguardDuplicateWebsites,
|
||||
onClick = state.onClick,
|
||||
)
|
||||
}
|
||||
|
@ -934,7 +943,7 @@ private fun CardTrashedItems(
|
|||
)
|
||||
},
|
||||
text = stringResource(Res.strings.watchtower_item_trashed_items_text),
|
||||
imageVector = Icons.Outlined.Delete,
|
||||
imageVector = Icons.Outlined.KeyguardTrashedItems,
|
||||
onClick = state.onClick,
|
||||
)
|
||||
}
|
||||
|
@ -977,7 +986,7 @@ private fun CardDuplicateItems(
|
|||
)
|
||||
},
|
||||
text = stringResource(Res.strings.watchtower_item_duplicate_items_text),
|
||||
imageVector = Icons.Outlined.CopyAll,
|
||||
imageVector = Icons.Outlined.KeyguardDuplicateItems,
|
||||
onClick = state.onClick,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ data class WatchtowerState(
|
|||
data class Filter(
|
||||
val items: List<FilterItem> = emptyList(),
|
||||
val onClear: (() -> Unit)? = null,
|
||||
val onSave: (() -> Unit)? = null,
|
||||
)
|
||||
|
||||
data class Content(
|
||||
|
|
|
@ -141,7 +141,7 @@ fun produceWatchtowerState(
|
|||
}
|
||||
.shareIn(screenScope, SharingStarted.WhileSubscribed(), replay = 1)
|
||||
|
||||
val filterResult = createFilter()
|
||||
val filterResult = createFilter(directDI)
|
||||
|
||||
fun filteredCiphers(ciphersFlow: Flow<List<DSecret>>) = ciphersFlow
|
||||
.map {
|
||||
|
@ -993,6 +993,7 @@ fun produceWatchtowerState(
|
|||
filter = WatchtowerState.Filter(
|
||||
items = filterState.items,
|
||||
onClear = filterState.onClear,
|
||||
onSave = filterState.onSave,
|
||||
),
|
||||
actions = actions,
|
||||
)
|
||||
|
|
|
@ -8,3 +8,6 @@ fun Platform.hasAutofill(): Boolean =
|
|||
|
||||
fun Platform.hasSubscription(): Boolean =
|
||||
this is Platform.Mobile.Android
|
||||
|
||||
fun Platform.hasDynamicShortcuts(): Boolean =
|
||||
this is Platform.Mobile.Android
|
||||
|
|
|
@ -5,15 +5,26 @@ import androidx.compose.material.icons.materialIcon
|
|||
import androidx.compose.material.icons.materialPath
|
||||
import androidx.compose.material.icons.outlined.AccountTree
|
||||
import androidx.compose.material.icons.outlined.Attachment
|
||||
import androidx.compose.material.icons.outlined.Book
|
||||
import androidx.compose.material.icons.outlined.CloudOff
|
||||
import androidx.compose.material.icons.outlined.CollectionsBookmark
|
||||
import androidx.compose.material.icons.outlined.CopyAll
|
||||
import androidx.compose.material.icons.outlined.DataArray
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||
import androidx.compose.material.icons.outlined.FilterList
|
||||
import androidx.compose.material.icons.outlined.Key
|
||||
import androidx.compose.material.icons.outlined.Lens
|
||||
import androidx.compose.material.icons.outlined.LibraryBooks
|
||||
import androidx.compose.material.icons.outlined.List
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.NotificationsOff
|
||||
import androidx.compose.material.icons.outlined.Numbers
|
||||
import androidx.compose.material.icons.outlined.Recycling
|
||||
import androidx.compose.material.icons.outlined.ShortText
|
||||
import androidx.compose.material.icons.outlined.Star
|
||||
import androidx.compose.material.icons.outlined.StarBorder
|
||||
import androidx.compose.material.icons.outlined.StickyNote2
|
||||
import androidx.compose.material.icons.outlined.Timer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.Eye
|
||||
|
@ -28,6 +39,9 @@ val Icons.Outlined.KeyguardView
|
|||
val Icons.Outlined.KeyguardTwoFa
|
||||
get() = Numbers
|
||||
|
||||
val Icons.Outlined.KeyguardPasskey
|
||||
get() = Key
|
||||
|
||||
val Icons.Outlined.KeyguardNote
|
||||
get() = StickyNote2
|
||||
|
||||
|
@ -58,6 +72,48 @@ val Icons.Outlined.KeyguardPremium
|
|||
val Icons.Outlined.KeyguardWordlist
|
||||
get() = LibraryBooks
|
||||
|
||||
val Icons.Outlined.KeyguardCipherFilter
|
||||
get() = FilterList
|
||||
|
||||
val Icons.Outlined.KeyguardPwnedPassword
|
||||
get() = DataArray
|
||||
|
||||
val Icons.Outlined.KeyguardReusedPassword
|
||||
get() = Recycling
|
||||
|
||||
val Icons.Outlined.KeyguardPwnedWebsites
|
||||
get() = KeyguardWebsite
|
||||
|
||||
val Icons.Outlined.KeyguardUnsecureWebsites
|
||||
get() = KeyguardWebsite
|
||||
|
||||
val Icons.Outlined.KeyguardDuplicateWebsites
|
||||
get() = KeyguardWebsite
|
||||
|
||||
val Icons.Outlined.KeyguardDuplicateItems
|
||||
get() = CopyAll
|
||||
|
||||
val Icons.Outlined.KeyguardIncompleteItems
|
||||
get() = ShortText
|
||||
|
||||
val Icons.Outlined.KeyguardExpiringItems
|
||||
get() = Timer
|
||||
|
||||
val Icons.Outlined.KeyguardTrashedItems
|
||||
get() = Delete
|
||||
|
||||
val Icons.Outlined.KeyguardFailedItems
|
||||
get() = ErrorOutline
|
||||
|
||||
val Icons.Outlined.KeyguardPendingSyncItems
|
||||
get() = CloudOff
|
||||
|
||||
val Icons.Outlined.KeyguardAuthReprompt
|
||||
get() = Lock
|
||||
|
||||
val Icons.Outlined.KeyguardIgnoredAlerts
|
||||
get() = NotificationsOff
|
||||
|
||||
val Icons.Stub: ImageVector
|
||||
get() {
|
||||
if (_stub != null) {
|
||||
|
|
|
@ -192,6 +192,9 @@
|
|||
<string name="master_password_hint">Master password hint</string>
|
||||
<string name="fingerprint_phrase">Fingerprint phrase</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_unofficial_server">Unofficial Bitwarden server</string>
|
||||
|
||||
|
@ -671,6 +674,14 @@
|
|||
<string name="passwords_fair_label">Fair 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_empty_label">No filters available</string>
|
||||
|
||||
|
|
|
@ -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);
|
|
@ -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
|
||||
);
|
|
@ -28,6 +28,7 @@ androidxBrowser = "1.7.0"
|
|||
androidxCamera = "1.4.0-alpha04"
|
||||
androidxCoreKtx = "1.12.0"
|
||||
androidxCoreSplash = "1.1.0-alpha02"
|
||||
androidxCoreShortcuts = "1.0.0"
|
||||
androidxCredentials = "1.2.0"
|
||||
androidxDatastore = "1.0.0"
|
||||
androidxLifecycle = "2.7.0"
|
||||
|
@ -160,6 +161,7 @@ androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", versi
|
|||
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidxCamera" }
|
||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCoreKtx" }
|
||||
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidxCoreSplash" }
|
||||
androidx-core-shortcuts = { module = "androidx.core:core-google-shortcuts", version.ref = "androidxCoreShortcuts" }
|
||||
androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "androidxCredentials" }
|
||||
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidxDatastore" }
|
||||
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxTestEspresso" }
|
||||
|
|
Loading…
Reference in New Issue