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