feat: Custom filters

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

View File

@ -1,11 +1,16 @@
package com.artemchep.keyguard
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.artemchep.bindin.bindBlock
import com.artemchep.keyguard.android.BaseApp
import com.artemchep.keyguard.android.MainActivity
import com.artemchep.keyguard.android.downloader.journal.DownloadRepository
import com.artemchep.keyguard.android.downloader.worker.AttachmentDownloadAllWorker
import com.artemchep.keyguard.android.passkeysModule
@ -22,6 +27,7 @@ import com.artemchep.keyguard.core.session.diFingerprintRepositoryModule
import com.artemchep.keyguard.common.model.MasterSession
import com.artemchep.keyguard.common.service.vault.KeyReadWriteRepository
import com.artemchep.keyguard.common.model.PersistedSession
import com.artemchep.keyguard.common.service.filter.GetCipherFilters
import com.artemchep.keyguard.feature.favicon.Favicon
import com.artemchep.keyguard.feature.localization.textResource
import com.artemchep.keyguard.platform.LeContext
@ -223,5 +229,53 @@ class Main : BaseApp(), DIAware {
}
.launchIn(this)
}
// shortcuts
ProcessLifecycleOwner.get().lifecycleScope.launch {
getVaultSession()
.flatMapLatest { session ->
when (session) {
is MasterSession.Key -> {
val getCipherFilters: GetCipherFilters = session.di.direct.instance()
getCipherFilters()
}
is MasterSession.Empty -> emptyFlow()
}
}
.onEach { filters ->
val dynamicShortcutsIdsToRemove = kotlin.run {
val oldDynamicShortcutsIds =
ShortcutManagerCompat.getDynamicShortcuts(this@Main)
.map { it.id }
.toSet()
val newDynamicShortcutsIds = filters
.map { it.id }
.toSet()
oldDynamicShortcutsIds - newDynamicShortcutsIds
}
if (dynamicShortcutsIdsToRemove.isNotEmpty()) {
val ids = dynamicShortcutsIdsToRemove.toList()
ShortcutManagerCompat.removeDynamicShortcuts(this@Main, ids)
}
val shortcuts = filters
.map {
val intent = MainActivity.getIntent(this@Main).apply {
action = Intent.ACTION_VIEW
putExtra("customFilter", it.id)
}
val icon = IconCompat.createWithResource(this@Main, com.artemchep.keyguard.common.R.drawable.ic_shortcut_keyguard)
ShortcutInfoCompat.Builder(this@Main, it.id)
.setIcon(icon)
.setShortLabel(it.name)
.setIntent(intent)
.addCapabilityBinding("actions.intent.OPEN_APP_FEATURE")
.build()
}
ShortcutManagerCompat.addDynamicShortcuts(this@Main, shortcuts)
}
.launchIn(this)
}
}
}

View File

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

View File

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

View File

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

View File

@ -1,20 +1,2 @@
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<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>

View File

@ -0,0 +1,25 @@
package com.artemchep.keyguard.common.model
import androidx.compose.ui.graphics.vector.ImageVector
import com.artemchep.keyguard.ui.icons.generateAccentColors
import kotlinx.datetime.Instant
data class DCipherFilter(
val idRaw: Long,
val icon: ImageVector?,
val name: String,
val filter: Map<String, Set<DFilter.Primitive>>,
val updatedDate: Instant,
val createdDate: Instant,
) : Comparable<DCipherFilter> {
val id = idRaw.toString()
val accentColor = run {
val colors = generateAccentColors(name)
colors
}
override fun compareTo(other: DCipherFilter): Int {
return name.compareTo(other.name, ignoreCase = true)
}
}

View File

@ -1,5 +1,9 @@
package com.artemchep.keyguard.common.model
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.outlined.Password
import androidx.compose.ui.graphics.vector.ImageVector
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.partially1
@ -22,11 +26,29 @@ import com.artemchep.keyguard.common.usecase.CipherUnsecureUrlCheck
import com.artemchep.keyguard.common.usecase.CipherUrlDuplicateCheck
import com.artemchep.keyguard.core.store.bitwarden.exists
import com.artemchep.keyguard.feature.crashlytics.crashlyticsTap
import com.artemchep.keyguard.feature.home.vault.component.obscurePassword
import com.artemchep.keyguard.feature.localization.TextHolder
import com.artemchep.keyguard.provider.bitwarden.entity.HibpBreachGroup
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.icons.KeyguardAttachment
import com.artemchep.keyguard.ui.icons.KeyguardAuthReprompt
import com.artemchep.keyguard.ui.icons.KeyguardDuplicateWebsites
import com.artemchep.keyguard.ui.icons.KeyguardExpiringItems
import com.artemchep.keyguard.ui.icons.KeyguardFailedItems
import com.artemchep.keyguard.ui.icons.KeyguardIgnoredAlerts
import com.artemchep.keyguard.ui.icons.KeyguardIncompleteItems
import com.artemchep.keyguard.ui.icons.KeyguardPasskey
import com.artemchep.keyguard.ui.icons.KeyguardPendingSyncItems
import com.artemchep.keyguard.ui.icons.KeyguardPwnedPassword
import com.artemchep.keyguard.ui.icons.KeyguardPwnedWebsites
import com.artemchep.keyguard.ui.icons.KeyguardReusedPassword
import com.artemchep.keyguard.ui.icons.KeyguardTwoFa
import com.artemchep.keyguard.ui.icons.KeyguardUnsecureWebsites
import io.ktor.http.Url
import kotlinx.datetime.Clock
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.kodein.di.DirectDI
import org.kodein.di.instance
import kotlin.collections.Collection
@ -89,7 +111,24 @@ sealed interface DFilter {
_findOne(f, target, predicate)
}
else -> Either.Left(Unit)
else -> kotlin.run {
filter.filters.forEach { f ->
val r = _findOne(f, target, predicate)
r.fold(
ifLeft = {
return@run r
},
ifRight = {
if (it != null) {
return@run Either.Left(Unit)
}
it
},
)
}
Either.Right(null)
}
}
}
@ -128,6 +167,52 @@ sealed interface DFilter {
}
}
}
inline fun <reified T> findAny(
filter: DFilter,
noinline predicate: (T) -> Boolean = { true },
): T? = findAny(
filter = filter,
target = T::class.java,
predicate = predicate,
)
fun <T> findAny(
filter: DFilter,
target: Class<T>,
predicate: (T) -> Boolean = { true },
): T? = _findAny(
filter = filter,
target = target,
predicate = predicate,
)
private fun <T> _findAny(
filter: DFilter,
target: Class<T>,
predicate: (T) -> Boolean = { true },
): T? = when (filter) {
is Or<*> -> filter
.filters
.firstNotNullOfOrNull { f ->
_findAny(f, target, predicate)
}
is And<*> -> filter
.filters
.firstNotNullOfOrNull { f ->
_findAny(f, target, predicate)
}
else -> {
if (filter.javaClass == target) {
val f = filter as T
f.takeIf(predicate)
} else {
null
}
}
}
}
suspend fun prepare(
@ -145,6 +230,21 @@ sealed interface DFilter {
val key: String
}
@Serializable
sealed interface PrimitiveSpecial : Primitive {
}
@Serializable
sealed interface PrimitiveSimple : Primitive {
data class Content(
val title: TextHolder,
val icon: ImageVector? = null,
)
@Transient
val content: Content
}
@Serializable
@SerialName("or")
data class Or<out T : DFilter>(
@ -258,7 +358,8 @@ sealed interface DFilter {
data class ById(
val id: String?,
val what: What,
) : Primitive {
) : PrimitiveSpecial {
@Transient
override val key: String = "$id|$what"
@Serializable
@ -328,10 +429,19 @@ sealed interface DFilter {
@Serializable
@SerialName("by_type")
data class ByType(
@SerialName("cipherType")
val type: DSecret.Type,
) : Primitive {
) : PrimitiveSimple {
@Transient
override val key: String = "$type"
@Transient
override val content = PrimitiveSimple.Content(
title = type.titleH()
.let(TextHolder::Res),
icon = type.iconImageVector(),
)
override suspend fun prepare(
directDI: DirectDI,
ciphers: List<DSecret>,
@ -344,9 +454,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_otp")
data object ByOtp : Primitive {
data object ByOtp : PrimitiveSimple {
@Transient
override val key: String = "otp"
@Transient
override val content = PrimitiveSimple.Content(
title = Res.strings.one_time_password
.let(TextHolder::Res),
icon = Icons.Outlined.KeyguardTwoFa,
)
override suspend fun prepare(
directDI: DirectDI,
ciphers: List<DSecret>,
@ -359,9 +477,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_attachments")
data object ByAttachments : Primitive {
data object ByAttachments : PrimitiveSimple {
@Transient
override val key: String = "attachments"
@Transient
override val content = PrimitiveSimple.Content(
title = Res.strings.attachments
.let(TextHolder::Res),
icon = Icons.Outlined.KeyguardAttachment,
)
override suspend fun prepare(
directDI: DirectDI,
ciphers: List<DSecret>,
@ -374,9 +500,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_passkeys")
data object ByPasskeys : Primitive {
data object ByPasskeys : PrimitiveSimple {
@Transient
override val key: String = "passkeys"
@Transient
override val content = PrimitiveSimple.Content(
title = Res.strings.passkeys
.let(TextHolder::Res),
icon = Icons.Outlined.Key,
)
override suspend fun prepare(
directDI: DirectDI,
ciphers: List<DSecret>,
@ -391,9 +525,17 @@ sealed interface DFilter {
@SerialName("by_pwd_value")
data class ByPasswordValue(
val value: String?,
) : Primitive {
) : PrimitiveSimple {
@Transient
override val key: String = "pwd_value|$value"
@Transient
override val content = PrimitiveSimple.Content(
title = value?.let(::obscurePassword).orEmpty()
.let(TextHolder::Value),
icon = Icons.Outlined.Password,
)
override suspend fun prepare(
directDI: DirectDI,
ciphers: List<DSecret>,
@ -408,8 +550,22 @@ sealed interface DFilter {
@SerialName("by_pwd_strength")
data class ByPasswordStrength(
val score: PasswordStrength.Score,
) : Primitive {
override val key: String = "$score"
) : PrimitiveSimple {
@Transient
override val key: String = "pwd_score|$score"
@Transient
override val content = PrimitiveSimple.Content(
title = when (score) {
PasswordStrength.Score.Weak -> Res.strings.passwords_weak_label
PasswordStrength.Score.Fair -> Res.strings.passwords_fair_label
PasswordStrength.Score.Good -> Res.strings.passwords_good_label
PasswordStrength.Score.Strong -> Res.strings.passwords_strong_label
PasswordStrength.Score.VeryStrong -> Res.strings.passwords_very_strong_label
}
.let(TextHolder::Res),
icon = Icons.Outlined.Password,
)
override suspend fun prepare(
directDI: DirectDI,
@ -423,9 +579,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_pwd_duplicates")
data object ByPasswordDuplicates : Primitive {
data object ByPasswordDuplicates : PrimitiveSimple {
@Transient
override val key: String = "pwd_duplicates"
@Transient
override val content = PrimitiveSimple.Content(
title = Res.strings.watchtower_item_reused_passwords_title
.let(TextHolder::Res),
icon = Icons.Outlined.KeyguardReusedPassword,
)
private data class DuplicatesState(
var duplicate: Int,
var ignored: Int,
@ -497,9 +661,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_pwd_pwned")
data object ByPasswordPwned : Primitive {
data object ByPasswordPwned : PrimitiveSimple {
@Transient
override val key: String = "pwd_pwned"
@Transient
override val content = PrimitiveSimple.Content(
title = Res.strings.watchtower_item_pwned_passwords_title
.let(TextHolder::Res),
icon = Icons.Outlined.KeyguardPwnedPassword,
)
override suspend fun prepare(
directDI: DirectDI,
ciphers: List<DSecret>,
@ -565,9 +737,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_website_pwned")
data object ByWebsitePwned : Primitive {
data object ByWebsitePwned : PrimitiveSimple {
@Transient
override val key: String = "website_pwned"
@Transient
override val content = PrimitiveSimple.Content(
title = Res.strings.watchtower_item_vulnerable_accounts_title
.let(TextHolder::Res),
icon = Icons.Outlined.KeyguardPwnedWebsites,
)
override suspend fun prepare(
directDI: DirectDI,
ciphers: List<DSecret>,
@ -627,9 +807,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_incomplete")
data object ByIncomplete : Primitive {
data object ByIncomplete : PrimitiveSimple {
@Transient
override val key: String = "incomplete"
@Transient
override val content = PrimitiveSimple.Content(
title = Res.strings.watchtower_item_incomplete_items_title
.let(TextHolder::Res),
icon = Icons.Outlined.KeyguardIncompleteItems,
)
override suspend fun prepare(
directDI: DirectDI,
ciphers: List<DSecret>,
@ -676,9 +864,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_expiring")
data object ByExpiring : Primitive {
data object ByExpiring : PrimitiveSimple {
@Transient
override val key: String = "expiring"
@Transient
override val content = PrimitiveSimple.Content(
title = Res.strings.watchtower_item_expiring_items_title
.let(TextHolder::Res),
icon = Icons.Outlined.KeyguardExpiringItems,
)
override suspend fun prepare(
directDI: DirectDI,
ciphers: List<DSecret>,
@ -726,9 +922,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_unsecure_websites")
data object ByUnsecureWebsites : Primitive {
data object ByUnsecureWebsites : PrimitiveSimple {
@Transient
override val key: String = "unsecure_websites"
@Transient
override val content = PrimitiveSimple.Content(
title = Res.strings.watchtower_item_unsecure_websites_title
.let(TextHolder::Res),
icon = Icons.Outlined.KeyguardUnsecureWebsites,
)
override suspend fun prepare(
directDI: DirectDI,
ciphers: List<DSecret>,
@ -777,9 +981,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_tfa_websites")
data object ByTfaWebsites : Primitive {
data object ByTfaWebsites : PrimitiveSimple {
@Transient
override val key: String = "tfa_websites"
@Transient
override val content = PrimitiveSimple.Content(
title = Res.strings.watchtower_item_inactive_2fa_title
.let(TextHolder::Res),
icon = Icons.Outlined.KeyguardTwoFa,
)
override suspend fun prepare(
directDI: DirectDI,
ciphers: List<DSecret>,
@ -874,9 +1086,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_passkey_websites")
data object ByPasskeyWebsites : Primitive {
data object ByPasskeyWebsites : PrimitiveSimple {
@Transient
override val key: String = "passkey_websites"
@Transient
override val content = PrimitiveSimple.Content(
title = Res.strings.watchtower_item_inactive_passkey_title
.let(TextHolder::Res),
icon = Icons.Outlined.KeyguardPasskey,
)
override suspend fun prepare(
directDI: DirectDI,
ciphers: List<DSecret>,
@ -983,9 +1203,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_duplicate_websites")
data object ByDuplicateWebsites : Primitive {
data object ByDuplicateWebsites : PrimitiveSimple {
@Transient
override val key: String = "duplicate_websites"
@Transient
override val content = PrimitiveSimple.Content(
title = Res.strings.watchtower_item_duplicate_websites_title
.let(TextHolder::Res),
icon = Icons.Outlined.KeyguardDuplicateWebsites,
)
override suspend fun prepare(
directDI: DirectDI,
ciphers: List<DSecret>,
@ -1049,9 +1277,17 @@ sealed interface DFilter {
@SerialName("by_sync")
data class BySync(
val synced: Boolean,
) : Primitive {
) : PrimitiveSimple {
@Transient
override val key: String = "$synced"
@Transient
override val content = PrimitiveSimple.Content(
title = Res.strings.filter_pending_items
.let(TextHolder::Res),
icon = Icons.Outlined.KeyguardPendingSyncItems,
)
override suspend fun prepare(
directDI: DirectDI,
ciphers: List<DSecret>,
@ -1075,9 +1311,17 @@ sealed interface DFilter {
@SerialName("by_repromt")
data class ByReprompt(
val reprompt: Boolean,
) : Primitive {
) : PrimitiveSimple {
@Transient
override val key: String = "$reprompt"
@Transient
override val content = PrimitiveSimple.Content(
title = Res.strings.filter_auth_reprompt_items
.let(TextHolder::Res),
icon = Icons.Outlined.KeyguardAuthReprompt,
)
override suspend fun prepare(
directDI: DirectDI,
ciphers: List<DSecret>,
@ -1092,9 +1336,17 @@ sealed interface DFilter {
@SerialName("by_error")
data class ByError(
val error: Boolean,
) : Primitive {
) : PrimitiveSimple {
@Transient
override val key: String = "$error"
@Transient
override val content = PrimitiveSimple.Content(
title = Res.strings.filter_failed_items
.let(TextHolder::Res),
icon = Icons.Outlined.KeyguardFailedItems,
)
override suspend fun prepare(
directDI: DirectDI,
ciphers: List<DSecret>,
@ -1116,9 +1368,17 @@ sealed interface DFilter {
@Serializable
@SerialName("by_ignored_alerts")
data object ByIgnoredAlerts : Primitive {
data object ByIgnoredAlerts : PrimitiveSimple {
@Transient
override val key: String = "ignored_alerts"
@Transient
override val content = PrimitiveSimple.Content(
title = Res.strings.ignored_alerts
.let(TextHolder::Res),
icon = Icons.Outlined.KeyguardIgnoredAlerts,
)
override suspend fun prepare(
directDI: DirectDI,
ciphers: List<DSecret>,

View File

@ -0,0 +1,6 @@
package com.artemchep.keyguard.common.service.filter
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.service.filter.model.AddCipherFilterRequest
interface AddCipherFilter : (AddCipherFilterRequest) -> IO<Unit>

View File

@ -0,0 +1,6 @@
package com.artemchep.keyguard.common.service.filter
import com.artemchep.keyguard.common.model.DCipherFilter
import kotlinx.coroutines.flow.Flow
interface GetCipherFilters : () -> Flow<List<DCipherFilter>>

View File

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

View File

@ -0,0 +1,6 @@
package com.artemchep.keyguard.common.service.filter
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.service.filter.model.RenameCipherFilterRequest
interface RenameCipherFilter : (RenameCipherFilterRequest) -> IO<Unit>

View File

@ -0,0 +1,9 @@
package com.artemchep.keyguard.common.service.filter.entity
import com.artemchep.keyguard.common.model.DFilter
import kotlinx.serialization.Serializable
@Serializable
data class FilterEntity(
val state: Map<String, Set<DFilter.Primitive>>,
)

View File

@ -0,0 +1,24 @@
package com.artemchep.keyguard.common.service.filter.impl
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.io.ioEffect
import com.artemchep.keyguard.common.service.filter.AddCipherFilter
import com.artemchep.keyguard.common.service.filter.model.AddCipherFilterRequest
import com.artemchep.keyguard.common.service.filter.repo.CipherFilterRepository
import org.kodein.di.DirectDI
import org.kodein.di.instance
class AddCipherFilterImpl(
private val cipherFilterRepository: CipherFilterRepository,
) : AddCipherFilter {
constructor(
directDI: DirectDI,
) : this(
cipherFilterRepository = directDI.instance(),
)
override fun invoke(
request: AddCipherFilterRequest,
): IO<Unit> = cipherFilterRepository
.post(data = request)
}

View File

@ -0,0 +1,23 @@
package com.artemchep.keyguard.common.service.filter.impl
import com.artemchep.keyguard.common.model.DCipherFilter
import com.artemchep.keyguard.common.model.DFilter
import com.artemchep.keyguard.common.service.filter.GetCipherFilters
import com.artemchep.keyguard.common.service.filter.repo.CipherFilterRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.datetime.Clock
import org.kodein.di.DirectDI
import org.kodein.di.instance
class GetCipherFiltersImpl(
private val cipherFilterRepository: CipherFilterRepository,
) : GetCipherFilters {
constructor(
directDI: DirectDI,
) : this(
cipherFilterRepository = directDI.instance(),
)
override fun invoke(): Flow<List<DCipherFilter>> = cipherFilterRepository.get()
}

View File

@ -0,0 +1,22 @@
package com.artemchep.keyguard.common.service.filter.impl
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.service.filter.RemoveCipherFilterById
import com.artemchep.keyguard.common.service.filter.repo.CipherFilterRepository
import org.kodein.di.DirectDI
import org.kodein.di.instance
class RemoveCipherFilterByIdImpl(
private val cipherFilterRepository: CipherFilterRepository,
) : RemoveCipherFilterById {
constructor(
directDI: DirectDI,
) : this(
cipherFilterRepository = directDI.instance(),
)
override fun invoke(
ids: Set<Long>,
): IO<Unit> = cipherFilterRepository
.removeByIds(ids)
}

View File

@ -0,0 +1,29 @@
package com.artemchep.keyguard.common.service.filter.impl
import com.artemchep.keyguard.common.io.bind
import com.artemchep.keyguard.common.io.ioEffect
import com.artemchep.keyguard.common.service.filter.RenameCipherFilter
import com.artemchep.keyguard.common.service.filter.model.RenameCipherFilterRequest
import com.artemchep.keyguard.common.service.filter.repo.CipherFilterRepository
import org.kodein.di.DirectDI
import org.kodein.di.instance
class RenameCipherFilterImpl(
private val cipherFilterRepository: CipherFilterRepository,
) : RenameCipherFilter {
constructor(directDI: DirectDI) : this(
cipherFilterRepository = directDI.instance(),
)
override fun invoke(
model: RenameCipherFilterRequest,
) = ioEffect {
val name = model.name
cipherFilterRepository
.patch(
id = model.id,
name = name,
)
.bind()
}
}

View File

@ -0,0 +1,11 @@
package com.artemchep.keyguard.common.service.filter.model
import com.artemchep.keyguard.common.model.DFilter
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
data class AddCipherFilterRequest(
val now: Instant = Clock.System.now(),
val name: String,
val filter: Map<String, Set<DFilter.Primitive>>,
)

View File

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

View File

@ -0,0 +1,25 @@
package com.artemchep.keyguard.common.service.filter.repo
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.model.DCipherFilter
import com.artemchep.keyguard.common.service.filter.model.AddCipherFilterRequest
import kotlinx.coroutines.flow.Flow
interface CipherFilterRepository {
fun get(): Flow<List<DCipherFilter>>
fun post(
data: AddCipherFilterRequest,
): IO<Unit>
fun patch(
id: Long,
name: String,
): IO<Unit>
fun removeAll(): IO<Unit>
fun removeByIds(
ids: Set<Long>,
): IO<Unit>
}

View File

@ -0,0 +1,230 @@
package com.artemchep.keyguard.common.service.filter.repo.impl
import androidx.compose.material.icons.Icons
import androidx.compose.ui.graphics.vector.ImageVector
import com.artemchep.keyguard.common.io.IO
import com.artemchep.keyguard.common.io.effectMap
import com.artemchep.keyguard.common.model.DCipherFilter
import com.artemchep.keyguard.common.model.DFilter
import com.artemchep.keyguard.common.model.DSecret
import com.artemchep.keyguard.common.model.iconImageVector
import com.artemchep.keyguard.common.service.filter.entity.FilterEntity
import com.artemchep.keyguard.common.service.filter.model.AddCipherFilterRequest
import com.artemchep.keyguard.common.service.filter.repo.CipherFilterRepository
import com.artemchep.keyguard.common.service.state.impl.toJson
import com.artemchep.keyguard.common.service.state.impl.toMap
import com.artemchep.keyguard.common.util.sqldelight.flatMapQueryToList
import com.artemchep.keyguard.core.store.DatabaseDispatcher
import com.artemchep.keyguard.core.store.DatabaseManager
import com.artemchep.keyguard.data.CipherFilter
import com.artemchep.keyguard.data.CipherFilterQueries
import com.artemchep.keyguard.feature.home.vault.screen.FilterSection
import com.artemchep.keyguard.feature.localization.textResource
import com.artemchep.keyguard.platform.LeContext
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.icons.KeyguardTwoFa
import dev.icerock.moko.resources.StringResource
import kotlinx.collections.immutable.toPersistentMap
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import org.kodein.di.DirectDI
import org.kodein.di.instance
class CipherFilterRepositoryImpl(
private val context: LeContext,
private val databaseManager: DatabaseManager,
private val json: Json,
private val dispatcher: CoroutineDispatcher,
) : CipherFilterRepository {
companion object {
private const val TYPE_LOGIN = "login"
private const val TYPE_CARD = "card"
private const val TYPE_IDENTITY = "identity"
private const val TYPE_NOTE = "note"
private const val TYPE_OTP = "otp"
}
private interface FilterEntityMapper {
fun map(
context: LeContext,
entity: CipherFilter,
): DCipherFilter
}
private class BaseFilterEntityMapper(
private val icon: ImageVector,
private val name: StringResource,
private val state: Map<String, Set<DFilter.Primitive>>,
) : FilterEntityMapper {
override fun map(
context: LeContext,
entity: CipherFilter,
): DCipherFilter {
val name = entity.name.takeIf { it.isNotBlank() }
?: textResource(
res = name,
context = context,
)
return DCipherFilter(
idRaw = entity.id,
icon = icon,
name = name,
filter = state,
updatedDate = entity.updatedAt,
createdDate = entity.createdAt,
)
}
}
private val filterEntityMappers = mapOf(
TYPE_LOGIN to BaseFilterEntityMapper(
icon = DSecret.Type.Login.iconImageVector(),
name = Res.strings.cipher_type_login,
state = mapOf(
FilterSection.TYPE.id to setOf(
DFilter.ByType(DSecret.Type.Login),
),
),
),
TYPE_CARD to BaseFilterEntityMapper(
icon = DSecret.Type.Card.iconImageVector(),
name = Res.strings.cipher_type_card,
state = mapOf(
FilterSection.TYPE.id to setOf(
DFilter.ByType(DSecret.Type.Card),
),
),
),
TYPE_IDENTITY to BaseFilterEntityMapper(
icon = DSecret.Type.Identity.iconImageVector(),
name = Res.strings.cipher_type_identity,
state = mapOf(
FilterSection.TYPE.id to setOf(
DFilter.ByType(DSecret.Type.Identity),
),
),
),
TYPE_NOTE to BaseFilterEntityMapper(
icon = DSecret.Type.SecureNote.iconImageVector(),
name = Res.strings.cipher_type_note,
state = mapOf(
FilterSection.TYPE.id to setOf(
DFilter.ByType(DSecret.Type.SecureNote),
),
),
),
TYPE_OTP to BaseFilterEntityMapper(
icon = Icons.Outlined.KeyguardTwoFa,
name = Res.strings.one_time_password,
state = mapOf(
FilterSection.MISC.id to setOf(
DFilter.ByOtp,
),
),
),
)
constructor(
directDI: DirectDI,
) : this(
context = directDI.instance(),
databaseManager = directDI.instance(),
json = directDI.instance(),
dispatcher = directDI.instance(tag = DatabaseDispatcher),
)
override fun get(): Flow<List<DCipherFilter>> =
daoEffect { dao ->
dao.get()
}
.flatMapQueryToList(dispatcher)
.map { entities ->
entities
.mapNotNull { entity ->
if (entity.type != null) {
val mapper = filterEntityMappers[entity.type]
// Unknown type, skip the entity.
?: return@mapNotNull null
return@mapNotNull mapper.map(
context = context,
entity = entity,
)
}
val data = kotlin
.runCatching {
val el = json.decodeFromString<FilterEntity>(entity.data_)
el
}
.getOrElse {
FilterEntity(
state = emptyMap(),
)
}
DCipherFilter(
idRaw = entity.id,
icon = null,
name = entity.name,
filter = data.state,
updatedDate = entity.updatedAt,
createdDate = entity.createdAt,
)
}
.sorted()
}
override fun post(
data: AddCipherFilterRequest,
): IO<Unit> = daoEffect { dao ->
val data2 = kotlin.run {
val el = FilterEntity(
state = data.filter,
)
json.encodeToString(el)
}
dao.insert(
name = data.name,
data = data2,
updatedAt = data.now,
createdAt = data.now,
icon = null,
)
}
override fun patch(
id: Long,
name: String,
): IO<Unit> = daoEffect { dao ->
dao.rename(
id = id,
name = name,
)
}
override fun removeAll(): IO<Unit> =
daoEffect { dao ->
dao.deleteAll()
}
override fun removeByIds(ids: Set<Long>): IO<Unit> =
daoEffect { dao ->
dao.transaction {
ids.forEach { id ->
dao.deleteByIds(id)
}
}
}
private inline fun <T> daoEffect(
crossinline block: suspend (CipherFilterQueries) -> T,
): IO<T> = databaseManager
.get()
.effectMap(dispatcher) { db ->
val dao = db.cipherFilterQueries
block(dao)
}
}

View File

@ -0,0 +1,22 @@
package com.artemchep.keyguard.common.util
fun String.nextSymbol(index: Int = 0): String {
val a = getOrNull(index)
?: return ""
if (a.isHighSurrogate() || a.isEmojiControl()) {
// Take the low surrogate pair too, as it formats
// human readable symbols.
return a.toString() + nextSymbol(index + 1)
}
// Check if the next symbol is not zero width space. If
// it is, then we have to take the symbol after it as well.
val b = getOrNull(index + 1)
if (b != null && b.isEmojiControl()) {
return a.toString() + b + nextSymbol(index + 2)
}
return a.toString()
}
private fun Char.isEmojiControl() =
this == '\u200d' ||
this == '\uFE0F'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,47 @@
package com.artemchep.keyguard.feature.filter
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.runtime.Composable
import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.Route
import com.artemchep.keyguard.feature.navigation.state.TranslatorScope
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.FlatItemAction
import com.artemchep.keyguard.ui.icons.ChevronIcon
import com.artemchep.keyguard.ui.icons.KeyguardCipherFilter
import com.artemchep.keyguard.ui.icons.iconSmall
object CipherFiltersRoute : Route {
const val ROUTER_NAME = "cipher_filters"
fun actionOrNull(
translator: TranslatorScope,
navigate: (NavigationIntent) -> Unit,
) = action(
translator = translator,
navigate = navigate,
)
fun action(
translator: TranslatorScope,
navigate: (NavigationIntent) -> Unit,
) = FlatItemAction(
leading = iconSmall(Icons.Outlined.KeyguardCipherFilter),
title = translator.translate(Res.strings.customfilters_header_title),
trailing = {
ChevronIcon()
},
onClick = {
val route = CipherFiltersRoute
val intent = NavigationIntent.NavigateToRoute(route)
navigate(intent)
},
)
@Composable
override fun Content() {
CipherFiltersScreen()
}
}

View File

@ -0,0 +1,20 @@
package com.artemchep.keyguard.feature.filter
import androidx.compose.runtime.Composable
import com.artemchep.keyguard.feature.filter.list.CipherFiltersListRoute
import com.artemchep.keyguard.feature.navigation.NavigationRouter
import com.artemchep.keyguard.feature.twopane.TwoPaneNavigationContent
import com.artemchep.keyguard.ui.screenMaxWidthCompact
@Composable
fun CipherFiltersScreen() {
NavigationRouter(
id = CipherFiltersRoute.ROUTER_NAME,
initial = CipherFiltersListRoute,
) { backStack ->
TwoPaneNavigationContent(
backStack,
detailPaneMaxWidth = screenMaxWidthCompact,
)
}
}

View File

@ -0,0 +1,11 @@
package com.artemchep.keyguard.feature.filter.list
import androidx.compose.runtime.Composable
import com.artemchep.keyguard.feature.navigation.Route
object CipherFiltersListRoute : Route {
@Composable
override fun Content() {
CipherFiltersListScreen()
}
}

View File

@ -0,0 +1,380 @@
package com.artemchep.keyguard.feature.filter.list
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp
import com.artemchep.keyguard.common.model.Loadable
import com.artemchep.keyguard.common.model.flatMap
import com.artemchep.keyguard.common.model.getOrNull
import com.artemchep.keyguard.feature.EmptySearchView
import com.artemchep.keyguard.feature.ErrorView
import com.artemchep.keyguard.feature.filter.view.CipherFilterViewFullRoute
import com.artemchep.keyguard.feature.generator.wordlist.view.WordlistViewRoute
import com.artemchep.keyguard.feature.home.vault.component.SearchTextField
import com.artemchep.keyguard.feature.home.vault.component.rememberSecretAccentColor
import com.artemchep.keyguard.feature.navigation.NavigationIcon
import com.artemchep.keyguard.feature.navigation.navigationNextEntryOrNull
import com.artemchep.keyguard.feature.twopane.LocalHasDetailPane
import com.artemchep.keyguard.platform.CurrentPlatform
import com.artemchep.keyguard.platform.util.hasDynamicShortcuts
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.AvatarBuilder
import com.artemchep.keyguard.ui.DefaultProgressBar
import com.artemchep.keyguard.ui.DefaultSelection
import com.artemchep.keyguard.ui.FlatItem
import com.artemchep.keyguard.ui.MediumEmphasisAlpha
import com.artemchep.keyguard.ui.ScaffoldLazyColumn
import com.artemchep.keyguard.ui.focus.FocusRequester2
import com.artemchep.keyguard.ui.focus.focusRequester2
import com.artemchep.keyguard.ui.icons.ChevronIcon
import com.artemchep.keyguard.ui.pulltosearch.PullToSearch
import com.artemchep.keyguard.ui.skeleton.SkeletonItem
import com.artemchep.keyguard.ui.theme.Dimens
import com.artemchep.keyguard.ui.theme.combineAlpha
import com.artemchep.keyguard.ui.theme.selectedContainer
import com.artemchep.keyguard.ui.toolbar.CustomToolbar
import com.artemchep.keyguard.ui.toolbar.content.CustomToolbarContent
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.withIndex
@Composable
fun CipherFiltersListScreen(
) {
val loadableState = produceCipherFiltersListState()
CipherFiltersListScreen(
loadableState = loadableState,
)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun CipherFiltersListScreen(
loadableState: Loadable<CipherFiltersListState>,
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val filterState = run {
val filterFlow = loadableState.getOrNull()?.filter
remember(filterFlow) {
filterFlow ?: MutableStateFlow(null)
}.collectAsState()
}
val listRevision =
loadableState.getOrNull()?.content?.getOrNull()?.getOrNull()?.revision
val listState = remember {
LazyListState(
firstVisibleItemIndex = 0,
firstVisibleItemScrollOffset = 0,
)
}
LaunchedEffect(listRevision) {
// TODO: How do you wait till the layout state start to represent
// the actual data?
val listSize =
loadableState.getOrNull()?.content?.getOrNull()?.getOrNull()?.items?.size
snapshotFlow { listState.layoutInfo.totalItemsCount }
.withIndex()
.filter {
it.index > 0 || it.value == listSize
}
.first()
listState.scrollToItem(0, 0)
}
val focusRequester = remember { FocusRequester2() }
// Auto focus the text field
// on launch.
LaunchedEffect(
focusRequester,
filterState,
) {
snapshotFlow { filterState.value }
.first { it?.query?.onChange != null }
delay(100L)
focusRequester.requestFocus()
}
val pullRefreshState = rememberPullRefreshState(
refreshing = false,
onRefresh = {
focusRequester.requestFocus()
},
)
ScaffoldLazyColumn(
modifier = Modifier
.pullRefresh(pullRefreshState)
.nestedScroll(scrollBehavior.nestedScrollConnection),
topAppBarScrollBehavior = scrollBehavior,
topBar = {
CustomToolbar(
scrollBehavior = scrollBehavior,
) {
Column {
CustomToolbarContent(
title = stringResource(Res.strings.customfilters_header_title),
icon = {
NavigationIcon()
},
)
val query = filterState.value?.query
val queryText = query?.state?.value.orEmpty()
val count = loadableState
.getOrNull()
?.content
?.getOrNull()
?.getOrNull()
?.items
?.size
SearchTextField(
modifier = Modifier
.focusRequester2(focusRequester),
text = queryText,
placeholder = stringResource(Res.strings.customfilters_search_placeholder),
searchIcon = false,
count = count,
leading = {},
trailing = {},
onTextChange = query?.onChange,
onGoClick = null,
)
}
}
},
bottomBar = {
when (loadableState) {
is Loadable.Ok -> {
val selectionOrNull by loadableState.value.selection.collectAsState()
DefaultSelection(
state = selectionOrNull,
)
}
is Loadable.Loading -> {
// Do nothing
}
}
},
pullRefreshState = pullRefreshState,
overlay = {
val filterRevision = filterState.value?.revision
DefaultProgressBar(
visible = listRevision != null && filterRevision != null &&
listRevision != filterRevision,
)
PullToSearch(
modifier = Modifier
.padding(contentPadding.value),
pullRefreshState = pullRefreshState,
)
},
listState = listState,
) {
val contentState = loadableState
.flatMap { it.content }
when (contentState) {
is Loadable.Loading -> {
for (i in 1..3) {
item("skeleton.$i") {
SkeletonItem()
}
}
}
is Loadable.Ok -> {
contentState.value.fold(
ifLeft = { e ->
item("error") {
ErrorView(
text = {
Text(text = "Failed to load filters list!")
},
exception = e,
)
}
},
ifRight = { content ->
val items = content.items
if (items.isEmpty()) {
item("empty") {
NoItemsPlaceholder()
}
}
items(
items = items,
key = { it.key },
) { item ->
FilterItem(
modifier = Modifier
.animateItemPlacement(),
item = item,
)
}
},
)
}
}
if (CurrentPlatform.hasDynamicShortcuts()) item("note") {
Spacer(
modifier = Modifier
.height(32.dp),
)
Icon(
modifier = Modifier
.padding(horizontal = Dimens.horizontalPadding),
imageVector = Icons.Outlined.Info,
contentDescription = null,
tint = LocalContentColor.current.combineAlpha(alpha = MediumEmphasisAlpha),
)
Spacer(
modifier = Modifier
.height(16.dp),
)
Text(
modifier = Modifier
.padding(horizontal = Dimens.horizontalPadding),
text = stringResource(Res.strings.customfilters_dynamic_shortcut_tip),
style = MaterialTheme.typography.bodyMedium,
color = LocalContentColor.current
.combineAlpha(alpha = MediumEmphasisAlpha),
)
}
}
}
@Composable
private fun NoItemsPlaceholder(
modifier: Modifier = Modifier,
) {
EmptySearchView(
modifier = modifier,
)
}
@Composable
private fun FilterItem(
modifier: Modifier,
item: CipherFiltersListState.Item,
) {
val selectableState by item.selectableState.collectAsState()
val onClick = selectableState.onClick
// fallback to default
?: item.onClick
.takeIf { selectableState.can }
val onLongClick = selectableState.onLongClick
val backgroundColor = when {
selectableState.selected -> MaterialTheme.colorScheme.primaryContainer
else -> run {
if (LocalHasDetailPane.current) {
val nextEntry = navigationNextEntryOrNull()
val nextRoute = nextEntry?.route as? CipherFilterViewFullRoute
val selected = nextRoute?.args?.model?.id == item.key
if (selected) {
return@run MaterialTheme.colorScheme.selectedContainer
}
}
Color.Unspecified
}
}
FlatItem(
modifier = modifier,
backgroundColor = backgroundColor,
leading = {
val accent = rememberSecretAccentColor(
accentLight = item.accentLight,
accentDark = item.accentDark,
)
AvatarBuilder(
icon = item.icon,
accent = accent,
active = true,
badge = {
// Do nothing.
},
)
},
title = {
Text(item.name)
},
trailing = {
Spacer(
modifier = Modifier
.width(8.dp),
)
val checkbox = when {
selectableState.selecting -> true
else -> false
}
Crossfade(
modifier = Modifier
.size(
width = 36.dp,
height = 36.dp,
),
targetState = checkbox,
) {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
if (it) {
Checkbox(
checked = selectableState.selected,
onCheckedChange = null,
)
} else {
ChevronIcon()
}
}
}
},
onClick = onClick,
onLongClick = onLongClick,
)
}

View File

@ -0,0 +1,47 @@
package com.artemchep.keyguard.feature.filter.list
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import arrow.core.Either
import com.artemchep.keyguard.common.model.DCipherFilter
import com.artemchep.keyguard.common.model.Loadable
import com.artemchep.keyguard.feature.attachments.SelectableItemState
import com.artemchep.keyguard.feature.auth.common.TextFieldModel2
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
import com.artemchep.keyguard.ui.Selection
import kotlinx.coroutines.flow.StateFlow
data class CipherFiltersListState(
val filter: StateFlow<Filter>,
val selection: StateFlow<Selection?>,
val content: Loadable<Either<Throwable, Content>>,
) {
@Immutable
data class Filter(
val revision: Int,
val query: TextFieldModel2,
) {
companion object
}
@Immutable
data class Content(
val revision: Int,
val items: List<Item>,
) {
companion object
}
@Immutable
data class Item(
val key: String,
val icon: VaultItemIcon,
val accentLight: Color,
val accentDark: Color,
val name: AnnotatedString,
val data: DCipherFilter,
val selectableState: StateFlow<SelectableItemState>,
val onClick: () -> Unit,
)
}

View File

@ -0,0 +1,281 @@
package com.artemchep.keyguard.feature.filter.list
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.AnnotatedString
import arrow.core.partially1
import com.artemchep.keyguard.common.model.DCipherFilter
import com.artemchep.keyguard.common.model.Loadable
import com.artemchep.keyguard.common.service.filter.GetCipherFilters
import com.artemchep.keyguard.common.service.filter.RemoveCipherFilterById
import com.artemchep.keyguard.common.service.filter.RenameCipherFilter
import com.artemchep.keyguard.common.util.flow.persistingStateIn
import com.artemchep.keyguard.feature.attachments.SelectableItemState
import com.artemchep.keyguard.feature.attachments.SelectableItemStateRaw
import com.artemchep.keyguard.feature.crashlytics.crashlyticsAttempt
import com.artemchep.keyguard.feature.filter.CipherFiltersRoute
import com.artemchep.keyguard.feature.filter.util.CipherFilterUtil
import com.artemchep.keyguard.feature.filter.view.CipherFilterViewDialogRoute
import com.artemchep.keyguard.feature.filter.view.CipherFilterViewFullRoute
import com.artemchep.keyguard.feature.generator.wordlist.util.WordlistUtil
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
import com.artemchep.keyguard.feature.home.vault.model.short
import com.artemchep.keyguard.feature.home.vault.search.IndexedText
import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.state.produceScreenState
import com.artemchep.keyguard.feature.search.search.IndexedModel
import com.artemchep.keyguard.feature.search.search.mapSearch
import com.artemchep.keyguard.feature.search.search.searchFilter
import com.artemchep.keyguard.feature.search.search.searchQueryHandle
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.FlatItemAction
import com.artemchep.keyguard.ui.Selection
import com.artemchep.keyguard.ui.buildContextItems
import com.artemchep.keyguard.ui.icons.icon
import com.artemchep.keyguard.ui.selection.selectionHandle
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import org.kodein.di.compose.localDI
import org.kodein.di.direct
import org.kodein.di.instance
private class CipherFiltersListUiException(
msg: String,
cause: Throwable,
) : RuntimeException(msg, cause)
@Composable
fun produceCipherFiltersListState(
) = with(localDI().direct) {
produceCipherFiltersListState(
getCipherFilters = instance(),
removeCipherFilterById = instance(),
renameCipherFilter = instance(),
)
}
@Composable
fun produceCipherFiltersListState(
getCipherFilters: GetCipherFilters,
removeCipherFilterById: RemoveCipherFilterById,
renameCipherFilter: RenameCipherFilter,
): Loadable<CipherFiltersListState> = produceScreenState(
key = "cipher_filter_list",
initial = Loadable.Loading,
args = arrayOf(),
) {
val selectionHandle = selectionHandle("selection")
val queryHandle = searchQueryHandle("query")
val queryFlow = searchFilter(queryHandle) { model, revision ->
CipherFiltersListState.Filter(
revision = revision,
query = model,
)
}
fun onClick(model: DCipherFilter) {
val route = CipherFilterViewFullRoute(
args = CipherFilterViewDialogRoute.Args(
model = model,
),
)
val intent = NavigationIntent.Composite(
listOf(
NavigationIntent.PopById(CipherFiltersRoute.ROUTER_NAME),
NavigationIntent.NavigateToRoute(route),
),
)
navigate(intent)
}
suspend fun List<DCipherFilter>.toItems(): List<CipherFiltersListState.Item> {
return this
.map { filter ->
val id = filter.id
val icon = if (filter.icon != null) {
VaultItemIcon.VectorIcon(
imageVector = filter.icon,
)
} else {
VaultItemIcon.TextIcon.short(filter.name)
}
val selectableFlow = selectionHandle
.idsFlow
.map { selectedIds ->
SelectableItemStateRaw(
selecting = selectedIds.isNotEmpty(),
selected = id in selectedIds,
)
}
.distinctUntilChanged()
.map { raw ->
val onClick = if (raw.selecting) {
// lambda
selectionHandle::toggleSelection.partially1(id)
} else {
null
}
val onLongClick = if (raw.selecting) {
null
} else {
// lambda
selectionHandle::toggleSelection.partially1(id)
}
SelectableItemState(
selecting = raw.selecting,
selected = raw.selected,
onClick = onClick,
onLongClick = onLongClick,
)
}
val selectableStateFlow =
if (this.size >= 100) {
val sharing = SharingStarted.WhileSubscribed(1000L)
selectableFlow.persistingStateIn(this@produceScreenState, sharing)
} else {
selectableFlow.stateIn(this@produceScreenState)
}
CipherFiltersListState.Item(
key = id,
icon = icon,
accentLight = filter.accentColor.light,
accentDark = filter.accentColor.dark,
name = AnnotatedString(filter.name),
data = filter,
selectableState = selectableStateFlow,
onClick = ::onClick
.partially1(filter),
)
}
}
val itemsRawFlow = getCipherFilters()
val itemsFlow = itemsRawFlow
.map { filters ->
filters
.toItems()
// Index for the search.
.map { item ->
IndexedModel(
model = item,
indexedText = IndexedText.invoke(item.name.text),
)
}
}
.mapSearch(
handle = queryHandle,
) { item, result ->
// Replace the origin text with the one with
// search decor applied to it.
item.copy(name = result.highlightedText)
}
// Automatically de-select items
// that do not exist.
combine(
itemsRawFlow,
selectionHandle.idsFlow,
) { items, selectedItemIds ->
val newSelectedItemIds = selectedItemIds
.asSequence()
.filter { id ->
items.any { it.id == id }
}
.toSet()
newSelectedItemIds.takeIf { it.size < selectedItemIds.size }
}
.filterNotNull()
.onEach { ids -> selectionHandle.setSelection(ids) }
.launchIn(screenScope)
val selectionFlow = combine(
itemsRawFlow,
selectionHandle.idsFlow,
) { items, selectedItemIds ->
val selectedItems = items
.filter { it.id in selectedItemIds }
items to selectedItems
}
.map { (allItems, selectedItems) ->
if (selectedItems.isEmpty()) {
return@map null
}
val actions = buildContextItems {
if (selectedItems.size == 1) {
section {
val selectedItem = selectedItems.first()
this += FlatItemAction(
icon = Icons.Outlined.Edit,
title = translate(Res.strings.edit),
onClick = CipherFilterUtil::onRename
.partially1(this@produceScreenState)
.partially1(renameCipherFilter)
.partially1(selectedItem),
)
}
}
section {
this += FlatItemAction(
leading = icon(Icons.Outlined.Delete),
title = translate(Res.strings.delete),
onClick = CipherFilterUtil::onDeleteByItems
.partially1(this@produceScreenState)
.partially1(removeCipherFilterById)
.partially1(selectedItems),
)
}
}
Selection(
count = selectedItems.size,
actions = actions.toPersistentList(),
onSelectAll = if (selectedItems.size < allItems.size) {
val allIds = allItems
.asSequence()
.map { it.id }
.toSet()
selectionHandle::setSelection
.partially1(allIds)
} else {
null
},
onClear = selectionHandle::clearSelection,
)
}
.stateIn(screenScope)
val contentFlow = itemsFlow
.crashlyticsAttempt { e ->
val msg = "Failed to get the cipher filters list!"
CipherFiltersListUiException(
msg = msg,
cause = e,
)
}
.map { result ->
val contentOrException = result
.map { (items, revision) ->
CipherFiltersListState.Content(
revision = revision,
items = items,
)
}
Loadable.Ok(contentOrException)
}
contentFlow
.map { content ->
val state = CipherFiltersListState(
filter = queryFlow,
selection = selectionFlow,
content = content,
)
Loadable.Ok(state)
}
}

View File

@ -0,0 +1,94 @@
package com.artemchep.keyguard.feature.filter.util
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import com.artemchep.keyguard.common.io.launchIn
import com.artemchep.keyguard.common.model.DCipherFilter
import com.artemchep.keyguard.common.service.filter.RemoveCipherFilterById
import com.artemchep.keyguard.common.service.filter.RenameCipherFilter
import com.artemchep.keyguard.common.service.filter.model.RenameCipherFilterRequest
import com.artemchep.keyguard.feature.confirmation.ConfirmationResult
import com.artemchep.keyguard.feature.confirmation.ConfirmationRoute
import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogIntent
import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver
import com.artemchep.keyguard.feature.navigation.state.RememberStateFlowScope
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.icons.KeyguardCipherFilter
import com.artemchep.keyguard.ui.icons.KeyguardWordlist
import com.artemchep.keyguard.ui.icons.icon
object CipherFilterUtil {
context(RememberStateFlowScope)
fun onRename(
renameCipherFilter: RenameCipherFilter,
model: DCipherFilter,
) {
val nameKey = "name"
val nameItem = ConfirmationRoute.Args.Item.StringItem(
key = nameKey,
value = model.name,
title = translate(Res.strings.generic_name),
type = ConfirmationRoute.Args.Item.StringItem.Type.Text,
canBeEmpty = false,
)
val items = listOfNotNull(
nameItem,
)
val route = registerRouteResultReceiver(
route = ConfirmationRoute(
args = ConfirmationRoute.Args(
icon = icon(
main = Icons.Outlined.KeyguardCipherFilter,
secondary = Icons.Outlined.Edit,
),
title = translate(Res.strings.customfilters_edit_filter_title),
items = items,
docUrl = null,
),
),
) { result ->
if (result is ConfirmationResult.Confirm) {
val name = result.data[nameKey] as? String
?: return@registerRouteResultReceiver
val request = RenameCipherFilterRequest(
id = model.idRaw,
name = name,
)
renameCipherFilter(request)
.launchIn(appScope)
}
}
val intent = NavigationIntent.NavigateToRoute(route)
navigate(intent)
}
context(RememberStateFlowScope)
fun onDeleteByItems(
removeCipherFilterById: RemoveCipherFilterById,
items: List<DCipherFilter>,
) {
val title = if (items.size > 1) {
translate(Res.strings.customfilters_delete_many_confirmation_title)
} else {
translate(Res.strings.customfilters_delete_one_confirmation_title)
}
val message = items
.joinToString(separator = "\n") { it.name }
val intent = createConfirmationDialogIntent(
icon = icon(Icons.Outlined.Delete),
title = title,
message = message,
) {
val ids = items
.map { it.idRaw }
.toSet()
removeCipherFilterById(ids)
.launchIn(appScope)
}
navigate(intent)
}
}

View File

@ -0,0 +1,18 @@
package com.artemchep.keyguard.feature.filter.view
import androidx.compose.runtime.Composable
import com.artemchep.keyguard.common.model.DCipherFilter
import com.artemchep.keyguard.common.service.passkey.PassKeyServiceInfo
import com.artemchep.keyguard.feature.navigation.DialogRoute
data class CipherFilterViewDialogRoute(
val args: Args,
) : DialogRoute {
data class Args(
val model: DCipherFilter,
)
@Composable
override fun Content() {
}
}

View File

@ -0,0 +1,15 @@
package com.artemchep.keyguard.feature.filter.view
import androidx.compose.runtime.Composable
import com.artemchep.keyguard.feature.navigation.Route
data class CipherFilterViewFullRoute(
val args: CipherFilterViewDialogRoute.Args,
) : Route {
@Composable
override fun Content() {
CipherFilterViewFullScreen(
args = args,
)
}
}

View File

@ -0,0 +1,121 @@
package com.artemchep.keyguard.feature.filter.view
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import com.artemchep.keyguard.common.model.Loadable
import com.artemchep.keyguard.feature.navigation.NavigationIcon
import com.artemchep.keyguard.feature.search.filter.FilterItems
import com.artemchep.keyguard.feature.search.filter.FilterScreen
import com.artemchep.keyguard.ui.OptionsButton
import com.artemchep.keyguard.ui.ScaffoldColumn
import com.artemchep.keyguard.ui.toolbar.LargeToolbar
@Composable
fun CipherFilterViewFullScreen(
args: CipherFilterViewDialogRoute.Args,
) {
CipherFilterViewScreen(
args = args,
)
}
@Composable
fun CipherFilterViewScreen(
args: CipherFilterViewDialogRoute.Args,
) {
val loadableState = produceCipherFilterViewState(
args = args,
)
val title = args.model.name
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
when (loadableState) {
is Loadable.Ok -> {
val state = loadableState.value
CipherFilterViewScreenOk(
title = title,
scrollBehavior = scrollBehavior,
state = state,
)
}
is Loadable.Loading -> {
CipherFilterViewSkeleton(
title = title,
scrollBehavior = scrollBehavior,
)
}
}
}
@Composable
fun CipherFilterViewSkeleton(
title: String,
scrollBehavior: TopAppBarScrollBehavior,
) {
ScaffoldColumn(
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection),
topAppBarScrollBehavior = scrollBehavior,
topBar = {
LargeToolbar(
title = {
Text(
text = title,
)
},
navigationIcon = {
NavigationIcon()
},
scrollBehavior = scrollBehavior,
)
},
) {
}
}
@Composable
fun CipherFilterViewScreenOk(
title: String,
scrollBehavior: TopAppBarScrollBehavior,
state: CipherFilterViewState,
) {
val content by state.toolbarFlow.collectAsState()
ScaffoldColumn(
modifier = Modifier
.nestedScroll(scrollBehavior.nestedScrollConnection),
topAppBarScrollBehavior = scrollBehavior,
topBar = {
LargeToolbar(
title = {
Text(
text = content.model?.name
?: title,
)
},
navigationIcon = {
NavigationIcon()
},
actions = {
OptionsButton(
actions = content.actions,
)
},
scrollBehavior = scrollBehavior,
)
},
) {
val filter by state.filterFlow.collectAsState(
initial = CipherFilterViewState.Filter(),
)
FilterItems(
items = filter.items,
)
}
}

View File

@ -0,0 +1,36 @@
package com.artemchep.keyguard.feature.filter.view
import androidx.compose.runtime.Immutable
import arrow.core.Either
import com.artemchep.keyguard.common.model.DCipherFilter
import com.artemchep.keyguard.feature.home.vault.model.FilterItem
import com.artemchep.keyguard.ui.ContextItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
data class CipherFilterViewState(
val toolbarFlow: StateFlow<Toolbar>,
val filterFlow: Flow<Filter>,
val content: Either<Throwable, Content>,
val onClose: (() -> Unit)? = null,
) {
@Immutable
data class Content(
val model: DCipherFilter?,
) {
companion object
}
@Immutable
data class Toolbar(
val model: DCipherFilter?,
val actions: ImmutableList<ContextItem>,
)
@Immutable
data class Filter(
val items: ImmutableList<FilterItem> = persistentListOf(),
)
}

View File

@ -0,0 +1,315 @@
package com.artemchep.keyguard.feature.filter.view
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.FolderOff
import androidx.compose.runtime.Composable
import arrow.core.partially1
import arrow.core.right
import com.artemchep.keyguard.common.model.DFilter
import com.artemchep.keyguard.common.model.Loadable
import com.artemchep.keyguard.common.service.filter.GetCipherFilters
import com.artemchep.keyguard.common.service.filter.RemoveCipherFilterById
import com.artemchep.keyguard.common.service.filter.RenameCipherFilter
import com.artemchep.keyguard.common.usecase.GetAccounts
import com.artemchep.keyguard.common.usecase.GetCiphers
import com.artemchep.keyguard.common.usecase.GetCollections
import com.artemchep.keyguard.common.usecase.GetFolders
import com.artemchep.keyguard.common.usecase.GetOrganizations
import com.artemchep.keyguard.common.usecase.GetProfiles
import com.artemchep.keyguard.common.util.flow.foldAsList
import com.artemchep.keyguard.feature.filter.util.CipherFilterUtil
import com.artemchep.keyguard.feature.home.vault.model.FilterItem
import com.artemchep.keyguard.feature.home.vault.screen.FilterSection
import com.artemchep.keyguard.feature.navigation.state.navigatePopSelf
import com.artemchep.keyguard.feature.navigation.state.produceScreenState
import com.artemchep.keyguard.feature.navigation.state.translate
import com.artemchep.keyguard.res.Res
import com.artemchep.keyguard.ui.FlatItemAction
import com.artemchep.keyguard.ui.autoclose.launchAutoPopSelfHandler
import com.artemchep.keyguard.ui.buildContextItems
import com.artemchep.keyguard.ui.icons.iconSmall
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import org.kodein.di.compose.localDI
import org.kodein.di.direct
import org.kodein.di.instance
@Composable
fun produceCipherFilterViewState(
args: CipherFilterViewDialogRoute.Args,
) = with(localDI().direct) {
produceCipherFilterViewState(
args = args,
getCipherFilters = instance(),
removeCipherFilterById = instance(),
renameCipherFilter = instance(),
getAccounts = instance(),
getProfiles = instance(),
getOrganizations = instance(),
getCollections = instance(),
getFolders = instance(),
getCiphers = instance(),
)
}
@Composable
fun produceCipherFilterViewState(
args: CipherFilterViewDialogRoute.Args,
getCipherFilters: GetCipherFilters,
removeCipherFilterById: RemoveCipherFilterById,
renameCipherFilter: RenameCipherFilter,
getAccounts: GetAccounts,
getProfiles: GetProfiles,
getOrganizations: GetOrganizations,
getCollections: GetCollections,
getFolders: GetFolders,
getCiphers: GetCiphers,
): Loadable<CipherFilterViewState> = produceScreenState(
key = "cipher_filter_view",
initial = Loadable.Loading,
args = arrayOf(),
) {
val filterFlow = getCipherFilters()
.map { filters ->
filters
.firstOrNull { it.idRaw == args.model.idRaw }
}
.shareInScreenScope()
launchAutoPopSelfHandler(filterFlow)
val profilesMapFlow = getProfiles()
.map { profiles ->
profiles.associateBy { it.accountId }
}
val organizationsMapFlow = getOrganizations()
.map { organizations ->
organizations
.associate {
it.id to it.name
}
}
val collectionsMapFlow = getCollections()
.map { collections ->
collections
.associate {
it.id to it.name
}
}
val foldersMapFlow = getFolders()
.map { folders ->
folders
.associate {
it.id to it.name
}
}
val ciphersMapFlow = getCiphers()
.map { ciphers ->
ciphers
.associate {
it.id to it.name
}
}
val listFlow = filterFlow
.map { filter ->
filter
?: return@map emptyList<Flow<FilterItem>>()
val l = mutableListOf<Flow<FilterItem>>()
FilterSection.entries.forEach { filterSection ->
val filterSet = filter.filter[filterSection.id]
?: return@forEach
// Should never happen, but the structure
// definitely allows it.
if (filterSet.isEmpty()) {
return@forEach
}
fun createFilterItem(
filter: DFilter.Primitive,
leading: (@Composable () -> Unit)?,
title: String,
text: String? = null,
): FilterItem.Item {
return FilterItem.Item(
sectionId = filterSection.id,
filterSectionId = filterSection.id,
filter = FilterItem.Item.Filter.Toggle(
filters = setOf(filter),
),
checked = false,
enabled = true,
fill = false,
leading = leading,
title = title,
text = text,
onClick = null,
)
}
l += FilterItem.Section(
sectionId = filterSection.id,
text = translate(filterSection.title),
expandable = false,
onClick = null,
).let(::flowOf)
filterSet.forEach { f ->
when (f) {
is DFilter.PrimitiveSimple -> {
l += createFilterItem(
filter = f,
leading = f.content.icon
?.let {
iconSmall(it)
},
title = translate(f.content.title),
).let(::flowOf)
}
is DFilter.ById -> {
val sourceFlow = when (f.what) {
DFilter.ById.What.ACCOUNT -> {
l += profilesMapFlow
.map { state -> state[f.id] }
.distinctUntilChanged()
.map { profile ->
createFilterItem(
filter = f,
leading = null,
title = profile?.email.orEmpty(),
text = profile?.accountHost.orEmpty(),
)
}
return@forEach
}
DFilter.ById.What.FOLDER -> {
if (f.id == null) {
l += createFilterItem(
filter = f,
leading = iconSmall(Icons.Outlined.FolderOff),
title = translate(Res.strings.folder_none),
).let(::flowOf)
return@forEach
}
foldersMapFlow
}
DFilter.ById.What.COLLECTION -> {
if (f.id == null) {
l += createFilterItem(
filter = f,
leading = null,
title = translate(Res.strings.collection_none),
).let(::flowOf)
return@forEach
}
collectionsMapFlow
}
DFilter.ById.What.ORGANIZATION -> {
if (f.id == null) {
l += createFilterItem(
filter = f,
leading = null,
title = translate(Res.strings.organization_none),
).let(::flowOf)
return@forEach
}
organizationsMapFlow
}
DFilter.ById.What.CIPHER -> ciphersMapFlow
}
l += sourceFlow
.map { state -> state[f.id] }
.distinctUntilChanged()
.map { name ->
createFilterItem(
filter = f,
leading = null,
title = name.orEmpty(),
)
}
}
}
}
}
l
}
.flatMapLatest {
it.foldAsList()
}
.map {
CipherFilterViewState.Filter(
items = it.toPersistentList(),
)
}
val toolbarFlow = filterFlow
.map { filter ->
val actions = run {
filter
?: return@run persistentListOf()
buildContextItems {
section {
this += FlatItemAction(
icon = Icons.Outlined.Edit,
title = translate(Res.strings.edit),
onClick = CipherFilterUtil::onRename
.partially1(this@produceScreenState)
.partially1(renameCipherFilter)
.partially1(filter),
)
}
section {
val filterAsItems = listOf(
filter,
)
this += FlatItemAction(
icon = Icons.Outlined.Delete,
title = translate(Res.strings.delete),
onClick = CipherFilterUtil::onDeleteByItems
.partially1(this@produceScreenState)
.partially1(removeCipherFilterById)
.partially1(filterAsItems),
)
}
}
}
CipherFilterViewState.Toolbar(
model = filter,
actions = actions,
)
}
.stateIn(screenScope)
filterFlow
.map { model ->
val content = CipherFilterViewState.Content(
model = model,
)
val state = CipherFilterViewState(
toolbarFlow = toolbarFlow,
filterFlow = listFlow,
content = content
.right(),
onClose = {
navigatePopSelf()
},
)
Loadable.Ok(state)
}
}

View File

@ -24,6 +24,7 @@ import com.artemchep.keyguard.feature.confirmation.ConfirmationRoute
import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogIntent
import com.artemchep.keyguard.feature.crashlytics.crashlyticsAttempt
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
import com.artemchep.keyguard.feature.home.vault.model.short
import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver
import com.artemchep.keyguard.feature.navigation.state.produceScreenState
@ -292,18 +293,7 @@ fun produceEmailRelayListState(
)
}
}
val icon = VaultItemIcon.TextIcon(
run {
val words = it.name.split(" ")
if (words.size <= 1) {
return@run words.firstOrNull()?.take(2).orEmpty()
}
words
.take(2)
.joinToString("") { it.take(1) }
}.uppercase(),
)
val icon = VaultItemIcon.TextIcon.short(it.name)
val selectableFlow = selectionHandle
.idsFlow

View File

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

View File

@ -1,7 +1,6 @@
package com.artemchep.keyguard.feature.generator.wordlist.list
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddLink
import androidx.compose.material.icons.outlined.AttachFile
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Edit
@ -22,6 +21,7 @@ import com.artemchep.keyguard.feature.generator.wordlist.WordlistsRoute
import com.artemchep.keyguard.feature.generator.wordlist.util.WordlistUtil
import com.artemchep.keyguard.feature.generator.wordlist.view.WordlistViewRoute
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
import com.artemchep.keyguard.feature.home.vault.model.short
import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.state.produceScreenState
import com.artemchep.keyguard.res.Res
@ -128,7 +128,7 @@ fun produceWordlistListState(
this += FlatItemAction(
icon = Icons.Outlined.Edit,
title = translate(Res.strings.edit),
onClick = WordlistUtil::onEdit
onClick = WordlistUtil::onRename
.partially1(this@produceScreenState)
.partially1(editWordlist)
.partially1(selectedItem),
@ -166,18 +166,7 @@ fun produceWordlistListState(
.map { list ->
list
.map {
val icon = VaultItemIcon.TextIcon(
run {
val words = it.name.split(" ")
if (words.size <= 1) {
return@run words.firstOrNull()?.take(2).orEmpty()
}
words
.take(2)
.joinToString("") { it.take(1) }
}.uppercase(),
)
val icon = VaultItemIcon.TextIcon.short(it.name)
val selectableFlow = selectionHandle
.idsFlow

View File

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

View File

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

View File

@ -26,6 +26,7 @@ import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogInten
import com.artemchep.keyguard.feature.home.settings.accounts.model.AccountItem
import com.artemchep.keyguard.feature.home.vault.VaultRoute
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
import com.artemchep.keyguard.feature.home.vault.model.short
import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.state.produceScreenState
import com.artemchep.keyguard.res.Res
@ -235,18 +236,7 @@ fun accountListScreenState(
val busy = syncing || removing
val accent = profile?.accentColor
?: generateAccentColorsByAccountId(it.id.id)
val icon = VaultItemIcon.TextIcon(
run {
val words = profile?.name?.split(" ").orEmpty()
if (words.size <= 1) {
return@run words.firstOrNull()?.take(2).orEmpty()
}
words
.take(2)
.joinToString("") { it.take(1) }
}.uppercase(),
)
val icon = VaultItemIcon.TextIcon.short(profile?.name.orEmpty())
AccountItem.Item(
id = it.id.id,
icon = icon,

View File

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

View File

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

View File

@ -2,6 +2,8 @@ package com.artemchep.keyguard.feature.home.vault.model
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.vector.ImageVector
import com.artemchep.keyguard.common.util.asCodePointsSequence
import com.artemchep.keyguard.common.util.nextSymbol
import com.artemchep.keyguard.feature.favicon.AppIconUrl
import com.artemchep.keyguard.feature.favicon.FaviconUrl
import dev.icerock.moko.resources.ImageResource
@ -33,5 +35,30 @@ sealed interface VaultItemIcon {
@Immutable
data class TextIcon(
val text: String,
) : VaultItemIcon
) : VaultItemIcon {
companion object
}
}
fun VaultItemIcon.TextIcon.Companion.short(text: String): VaultItemIcon.TextIcon {
val abbr = kotlin.run {
val words = text.split(" ")
if (words.size <= 1) {
val word = words.firstOrNull()
?: return@run ""
return@run (0 until 2)
.fold("") { str, _ ->
str + word.nextSymbol(index = str.length)
}
}
words
.take(2)
.joinToString("") {
it.nextSymbol()
}
}
return VaultItemIcon.TextIcon(
text = abbr,
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,6 +9,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -18,6 +21,7 @@ import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -34,6 +38,7 @@ fun <T> DropdownButton(
title: String,
items: List<T>,
onClear: (() -> Unit)?,
onSave: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
var isExpanded by remember { mutableStateOf(false) }
@ -44,6 +49,18 @@ fun <T> DropdownButton(
isExpanded = false
}
}
val onExpandRequest = remember {
// lambda
{
isExpanded = true
}
}
val onDismissRequest = remember {
// lambda
{
isExpanded = false
}
}
Box(
modifier = modifier,
@ -55,9 +72,7 @@ fun <T> DropdownButton(
IconButton(
modifier = Modifier,
enabled = items.isNotEmpty(),
onClick = {
isExpanded = !isExpanded
},
onClick = onExpandRequest,
) {
Box {
Icon(icon, null)
@ -82,9 +97,6 @@ fun <T> DropdownButton(
// Inject the dropdown popup to the bottom of the
// content.
val onDismissRequest = {
isExpanded = false
}
DropdownMenu(
modifier = Modifier
.widthIn(
@ -99,7 +111,43 @@ fun <T> DropdownButton(
modifier = Modifier
.fillMaxWidth(),
title = title,
onClear = onClear,
actions = {
val updatedOnSave by rememberUpdatedState(onSave)
ExpandedIfNotEmptyForRow(
valueOrNull = onSave,
) {
IconButton(
onClick = {
// At first we hide the popup
// menu, because it launches a
// new route.
onDismissRequest()
// Launch the screen.
updatedOnSave?.invoke()
},
) {
Icon(
imageVector = Icons.Outlined.Save,
contentDescription = null,
)
}
}
val updatedOnClear by rememberUpdatedState(onClear)
ExpandedIfNotEmptyForRow(
valueOrNull = onClear,
) {
IconButton(
onClick = {
updatedOnClear?.invoke()
},
) {
Icon(
imageVector = Icons.Outlined.Clear,
contentDescription = null,
)
}
}
},
)
content()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -346,14 +346,6 @@ private fun SendListFilterScreen(
count = count,
items = filters,
onClear = clearFilters,
actions = {
SendListSortButton(
state = state,
)
OptionsButton(
actions = state.actions,
)
},
)
}
@ -365,11 +357,13 @@ private fun SendListFilterButton(
val count = (state.content as? SendListState.Content.Items)?.count
val filters = state.filters
val clearFilters = state.clearFilters
val saveFilters = state.saveFilters
FilterButton(
modifier = modifier,
count = count,
items = filters,
onClear = clearFilters,
onSave = saveFilters,
)
}

View File

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

View File

@ -24,6 +24,7 @@ import com.artemchep.keyguard.feature.confirmation.ConfirmationRoute
import com.artemchep.keyguard.feature.confirmation.createConfirmationDialogIntent
import com.artemchep.keyguard.feature.crashlytics.crashlyticsAttempt
import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon
import com.artemchep.keyguard.feature.home.vault.model.short
import com.artemchep.keyguard.feature.navigation.NavigationIntent
import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver
import com.artemchep.keyguard.feature.navigation.state.produceScreenState
@ -285,18 +286,7 @@ fun produceUrlOverrideListState(
)
}
}
val icon = VaultItemIcon.TextIcon(
run {
val words = it.name.split(" ")
if (words.size <= 1) {
return@run words.firstOrNull()?.take(2).orEmpty()
}
words
.take(2)
.joinToString("") { it.take(1) }
}.uppercase(),
)
val icon = VaultItemIcon.TextIcon.short(it.name)
val selectableFlow = selectionHandle
.idsFlow

View File

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

View File

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

View File

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

View File

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

View File

@ -5,15 +5,26 @@ import androidx.compose.material.icons.materialIcon
import androidx.compose.material.icons.materialPath
import androidx.compose.material.icons.outlined.AccountTree
import androidx.compose.material.icons.outlined.Attachment
import androidx.compose.material.icons.outlined.Book
import androidx.compose.material.icons.outlined.CloudOff
import androidx.compose.material.icons.outlined.CollectionsBookmark
import androidx.compose.material.icons.outlined.CopyAll
import androidx.compose.material.icons.outlined.DataArray
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.outlined.Lens
import androidx.compose.material.icons.outlined.LibraryBooks
import androidx.compose.material.icons.outlined.List
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.NotificationsOff
import androidx.compose.material.icons.outlined.Numbers
import androidx.compose.material.icons.outlined.Recycling
import androidx.compose.material.icons.outlined.ShortText
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material.icons.outlined.StickyNote2
import androidx.compose.material.icons.outlined.Timer
import androidx.compose.ui.graphics.vector.ImageVector
import compose.icons.FeatherIcons
import compose.icons.feathericons.Eye
@ -28,6 +39,9 @@ val Icons.Outlined.KeyguardView
val Icons.Outlined.KeyguardTwoFa
get() = Numbers
val Icons.Outlined.KeyguardPasskey
get() = Key
val Icons.Outlined.KeyguardNote
get() = StickyNote2
@ -58,6 +72,48 @@ val Icons.Outlined.KeyguardPremium
val Icons.Outlined.KeyguardWordlist
get() = LibraryBooks
val Icons.Outlined.KeyguardCipherFilter
get() = FilterList
val Icons.Outlined.KeyguardPwnedPassword
get() = DataArray
val Icons.Outlined.KeyguardReusedPassword
get() = Recycling
val Icons.Outlined.KeyguardPwnedWebsites
get() = KeyguardWebsite
val Icons.Outlined.KeyguardUnsecureWebsites
get() = KeyguardWebsite
val Icons.Outlined.KeyguardDuplicateWebsites
get() = KeyguardWebsite
val Icons.Outlined.KeyguardDuplicateItems
get() = CopyAll
val Icons.Outlined.KeyguardIncompleteItems
get() = ShortText
val Icons.Outlined.KeyguardExpiringItems
get() = Timer
val Icons.Outlined.KeyguardTrashedItems
get() = Delete
val Icons.Outlined.KeyguardFailedItems
get() = ErrorOutline
val Icons.Outlined.KeyguardPendingSyncItems
get() = CloudOff
val Icons.Outlined.KeyguardAuthReprompt
get() = Lock
val Icons.Outlined.KeyguardIgnoredAlerts
get() = NotificationsOff
val Icons.Stub: ImageVector
get() {
if (_stub != null) {

View File

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

View File

@ -0,0 +1,97 @@
import kotlinx.datetime.Instant;
CREATE TABLE cipherFilter (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
data TEXT NOT NULL,
updatedAt INTEGER AS Instant NOT NULL,
createdAt INTEGER AS Instant NOT NULL,
type TEXT,
icon TEXT
);
INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
VALUES (
'',
'',
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
'login',
NULL
);
INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
VALUES (
'',
'',
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
'card',
NULL
);
INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
VALUES (
'',
'',
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
'identity',
NULL
);
INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
VALUES (
'',
'',
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
'note',
NULL
);
INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
VALUES (
'',
'',
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
'otp',
NULL
);
update {
UPDATE cipherFilter
SET
name = :name,
data = :data,
updatedAt = :updatedAt,
createdAt = :createdAt
WHERE
id = :id;
}
rename {
UPDATE cipherFilter
SET
name = :name
WHERE
id = :id;
}
insert {
INSERT OR IGNORE INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
VALUES (:name, :data, :updatedAt, :createdAt, NULL, :icon);
}
get:
SELECT *
FROM cipherFilter
ORDER BY createdAt DESC;
deleteAll:
DELETE FROM cipherFilter;
deleteByIds:
DELETE FROM cipherFilter
WHERE id IN (:ids);

View File

@ -0,0 +1,59 @@
CREATE TABLE cipherFilter (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
data TEXT NOT NULL,
updatedAt INTEGER NOT NULL,
createdAt INTEGER NOT NULL,
type TEXT,
icon TEXT
);
INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
VALUES (
'',
'',
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
'login',
NULL
);
INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
VALUES (
'',
'',
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
'card',
NULL
);
INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
VALUES (
'',
'',
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
'identity',
NULL
);
INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
VALUES (
'',
'',
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
'note',
NULL
);
INSERT INTO cipherFilter(name, data, updatedAt, createdAt, type, icon)
VALUES (
'',
'',
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
CAST(unixepoch('subsecond') * 1000 AS INTEGER),
'otp',
NULL
);

View File

@ -28,6 +28,7 @@ androidxBrowser = "1.7.0"
androidxCamera = "1.4.0-alpha04"
androidxCoreKtx = "1.12.0"
androidxCoreSplash = "1.1.0-alpha02"
androidxCoreShortcuts = "1.0.0"
androidxCredentials = "1.2.0"
androidxDatastore = "1.0.0"
androidxLifecycle = "2.7.0"
@ -160,6 +161,7 @@ androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", versi
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidxCamera" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCoreKtx" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidxCoreSplash" }
androidx-core-shortcuts = { module = "androidx.core:core-google-shortcuts", version.ref = "androidxCoreShortcuts" }
androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "androidxCredentials" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidxDatastore" }
androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxTestEspresso" }