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