From d00781636dfbf00602ce7564ba438fc3282d3a09 Mon Sep 17 00:00:00 2001 From: Artem Chepurnoy Date: Tue, 26 Mar 2024 10:49:26 +0200 Subject: [PATCH] feat: Basic Sends editing capabilities --- .../artemchep/keyguard/common/model/DSend.kt | 3 +- .../keyguard/common/model/PatchSendRequest.kt | 20 + .../common/model/create/CreateSendRequest.kt | 53 + .../keyguard/common/usecase/AddSend.kt | 9 + .../keyguard/common/usecase/PatchSendById.kt | 8 + .../keyguard/common/usecase/RemoveSendById.kt | 7 + .../keyguard/common/usecase/SendToolbox.kt | 19 + .../usecase/impl/GetAccountStatusImpl.kt | 9 +- .../common/usecase/impl/GetEnvSendUrlImpl.kt | 11 +- .../keyguard/core/session/usecase/SubDI.kt | 24 + .../BitwardenOptionalStringNullable.kt | 24 + .../core/store/bitwarden/BitwardenSend.kt | 12 + .../keyguard/feature/add/AddScreen.kt | 1605 +++++++++++++++++ .../keyguard/feature/add/AddScreenScope.kt | 32 + .../keyguard/feature/add/AddStateItem.kt | 299 +++ .../keyguard/feature/add/AddStateOwnership.kt | 24 + .../OrganizationConfirmationRoute.kt | 10 + .../OrganizationConfirmationState.kt | 6 +- .../OrganizationConfirmationStateProducer.kt | 30 +- .../feature/home/vault/add/AddScreen.kt | 1401 +------------- .../feature/home/vault/add/AddState.kt | 286 +-- .../home/vault/add/AddStateProducer.kt | 306 ++-- .../SkeletonAttachmentItemFactory.kt | 9 +- .../home/vault/util/changePasswordAction.kt | 2 +- .../feature/send/SendListItemMapping.kt | 2 +- .../keyguard/feature/send/SendListState.kt | 5 +- .../feature/send/SendListStateProducer.kt | 114 +- .../keyguard/feature/send/add/SendAddRoute.kt | 24 + .../feature/send/add/SendAddScreen.kt | 204 +++ .../keyguard/feature/send/add/SendAddState.kt | 22 + .../feature/send/add/SendAddStateProducer.kt | 723 ++++++++ .../feature/send/list/SendListScreen.kt | 120 +- .../feature/send/search/SendListFilter.kt | 2 +- .../keyguard/feature/send/util/SendUtil.kt | 758 ++++++++ .../feature/send/view/SendViewState.kt | 3 +- .../send/view/SendViewStateProducer.kt | 42 +- .../provider/bitwarden/api/SyncEngine.kt | 167 +- .../bitwarden/api/builder/ServerEnvApi.kt | 27 +- .../bitwarden/crypto/BitwardenCrypto.kt | 77 +- .../provider/bitwarden/crypto/CipherCrypto.kt | 2 +- .../provider/bitwarden/crypto/SendCrypto.kt | 16 +- .../bitwarden/entity/api/LoginUriRequest.kt | 2 +- .../bitwarden/entity/request/SendRequest.kt | 109 +- .../bitwarden/entity/request/SendUpdate.kt | 49 + .../provider/bitwarden/mapper/SendMapping.kt | 8 +- .../provider/bitwarden/usecase/AddSend.kt | 209 +++ .../bitwarden/usecase/PatchSendById.kt | 70 + .../bitwarden/usecase/RemoveSendById.kt | 45 + .../bitwarden/usecase/util/ModifySendById.kt | 90 + .../commonMain/resources/MR/base/strings.xml | 30 + 50 files changed, 5121 insertions(+), 2008 deletions(-) create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/PatchSendRequest.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/create/CreateSendRequest.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/AddSend.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/PatchSendById.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/RemoveSendById.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/SendToolbox.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/core/store/bitwarden/BitwardenOptionalStringNullable.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/feature/add/AddScreen.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/feature/add/AddScreenScope.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/feature/add/AddStateItem.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/feature/add/AddStateOwnership.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/add/SendAddRoute.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/add/SendAddScreen.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/add/SendAddState.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/add/SendAddStateProducer.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/util/SendUtil.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/entity/request/SendUpdate.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/AddSend.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/PatchSendById.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/RemoveSendById.kt create mode 100644 common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/util/ModifySendById.kt diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/DSend.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/DSend.kt index a4124c0..c6c1932 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/DSend.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/DSend.kt @@ -36,7 +36,8 @@ data class DSend( val notes: String, val accessCount: Int, val maxAccessCount: Int? = null, - val password: String? = null, + val hasPassword: Boolean, + val synced: Boolean, val disabled: Boolean, val hideEmail: Boolean, val type: Type, diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/PatchSendRequest.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/PatchSendRequest.kt new file mode 100644 index 0000000..821cae4 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/PatchSendRequest.kt @@ -0,0 +1,20 @@ +package com.artemchep.keyguard.common.model + +import arrow.core.None +import arrow.core.Option + +data class PatchSendRequest( + val patch: Map, +) { + data class Data( + val name: Option = None, + val hideEmail: Option = None, + val disabled: Option = None, + val password: Option = None, + /** + * Changes the file name of the send. + * Note: only applied to sends with a file type. + */ + val fileName: Option = None, + ) +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/create/CreateSendRequest.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/create/CreateSendRequest.kt new file mode 100644 index 0000000..1299c23 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/create/CreateSendRequest.kt @@ -0,0 +1,53 @@ +package com.artemchep.keyguard.common.model.create + +import arrow.optics.optics +import com.artemchep.keyguard.common.model.DSecret +import com.artemchep.keyguard.common.model.DSend +import com.artemchep.keyguard.feature.confirmation.organization.FolderInfo +import com.artemchep.keyguard.platform.LeUri +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + +@optics +data class CreateSendRequest( + val ownership: Ownership? = null, + val title: String? = null, + val note: String? = null, + val favorite: Boolean? = null, + val reprompt: Boolean? = null, + val uris: PersistentList = persistentListOf(), + val fido2Credentials: PersistentList = persistentListOf(), + val fields: PersistentList = persistentListOf(), + // types + val type: DSend.Type? = null, + val text: Text = Text(), + val file: File = File(), + // other + val now: Instant, +) { + companion object; + + @optics + data class Ownership( + val accountId: String?, + ) { + companion object; + } + + @optics + data class File( + val text: String? = null, + ) { + companion object; + } + + @optics + data class Text( + val text: String? = null, + val hidden: Boolean? = null, + ) { + companion object; + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/AddSend.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/AddSend.kt new file mode 100644 index 0000000..1515a6d --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/AddSend.kt @@ -0,0 +1,9 @@ +package com.artemchep.keyguard.common.usecase + +import com.artemchep.keyguard.common.io.IO +import com.artemchep.keyguard.common.model.create.CreateRequest +import com.artemchep.keyguard.common.model.create.CreateSendRequest + +interface AddSend : ( + Map, +) -> IO> diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/PatchSendById.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/PatchSendById.kt new file mode 100644 index 0000000..3fb006c --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/PatchSendById.kt @@ -0,0 +1,8 @@ +package com.artemchep.keyguard.common.usecase + +import com.artemchep.keyguard.common.io.IO +import com.artemchep.keyguard.common.model.PatchSendRequest + +interface PatchSendById : ( + PatchSendRequest, +) -> IO diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/RemoveSendById.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/RemoveSendById.kt new file mode 100644 index 0000000..e8e3fd0 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/RemoveSendById.kt @@ -0,0 +1,7 @@ +package com.artemchep.keyguard.common.usecase + +import com.artemchep.keyguard.common.io.IO + +interface RemoveSendById : ( + Set, +) -> IO diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/SendToolbox.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/SendToolbox.kt new file mode 100644 index 0000000..cd3290e --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/SendToolbox.kt @@ -0,0 +1,19 @@ +package com.artemchep.keyguard.common.usecase + +import org.kodein.di.DirectDI +import org.kodein.di.instance + +interface SendToolbox { + val patchSendById: PatchSendById + val removeSendById: RemoveSendById +} + +class SendToolboxImpl( + override val patchSendById: PatchSendById, + override val removeSendById: RemoveSendById, +) : SendToolbox { + constructor(directDI: DirectDI) : this( + patchSendById = directDI.instance(), + removeSendById = directDI.instance(), + ) +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/GetAccountStatusImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/GetAccountStatusImpl.kt index b393589..0d3b3bb 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/GetAccountStatusImpl.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/GetAccountStatusImpl.kt @@ -10,6 +10,7 @@ import com.artemchep.keyguard.common.usecase.GetAccounts import com.artemchep.keyguard.common.usecase.GetCiphers import com.artemchep.keyguard.common.usecase.GetFolders import com.artemchep.keyguard.common.usecase.GetMetas +import com.artemchep.keyguard.common.usecase.GetSends import com.artemchep.keyguard.common.util.flow.combineToList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -24,6 +25,7 @@ class GetAccountStatusImpl( private val getMetas: GetMetas, private val getCiphers: GetCiphers, private val getFolders: GetFolders, + private val getSends: GetSends, ) : GetAccountStatus { private val importantPermissions = listOf( Permission.POST_NOTIFICATIONS, @@ -35,6 +37,7 @@ class GetAccountStatusImpl( getMetas = directDI.instance(), getCiphers = directDI.instance(), getFolders = directDI.instance(), + getSends = directDI.instance(), ) override fun invoke(): Flow { @@ -65,7 +68,11 @@ class GetAccountStatusImpl( .map { it.count { !it.synced } } - combine(c, f) { a, b -> a + b } + val s = getSends() + .map { + it.count { !it.synced } + } + combine(c, f, s) { a, b, s -> a + b + s } } val pendingPermissionsFlow = importantPermissions diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/GetEnvSendUrlImpl.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/GetEnvSendUrlImpl.kt index 1bd557a..973c308 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/GetEnvSendUrlImpl.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/common/usecase/impl/GetEnvSendUrlImpl.kt @@ -26,12 +26,15 @@ class GetEnvSendUrlImpl( ): IO = tokenRepository .getById(AccountId(send.accountId)) .effectMap { token -> - val sendBaseUrl = token?.env?.back()?.buildSendUrl() - requireNotNull(sendBaseUrl) { "Failed to get base send url." } + val baseUrl = token?.env?.back()?.buildSendUrl() + requireNotNull(baseUrl) { "Failed to get base send url." } + + val accessId = send.accessId.takeIf { it.isNotEmpty() } + requireNotNull(accessId) { "Failed to get access id." } val sendUrl = buildString { - append(sendBaseUrl) - append(send.accessId) + append(baseUrl) + append(accessId) append('/') val key = send.keyBase64 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 aaf2731..f681e20 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 @@ -44,6 +44,7 @@ import com.artemchep.keyguard.common.usecase.AddEmailRelay import com.artemchep.keyguard.common.usecase.AddFolder import com.artemchep.keyguard.common.usecase.AddGeneratorHistory import com.artemchep.keyguard.common.usecase.AddPasskeyCipher +import com.artemchep.keyguard.common.usecase.AddSend import com.artemchep.keyguard.common.usecase.AddUriCipher import com.artemchep.keyguard.common.usecase.AddUrlOverride import com.artemchep.keyguard.common.usecase.ChangeCipherNameById @@ -94,6 +95,7 @@ import com.artemchep.keyguard.common.usecase.GetUrlOverrides import com.artemchep.keyguard.common.usecase.GetWordlistPrimitive import com.artemchep.keyguard.common.usecase.MergeFolderById import com.artemchep.keyguard.common.usecase.MoveCipherToFolderById +import com.artemchep.keyguard.common.usecase.PatchSendById import com.artemchep.keyguard.common.usecase.PatchWatchtowerAlertCipher import com.artemchep.keyguard.common.usecase.PutAccountColorById import com.artemchep.keyguard.common.usecase.PutAccountMasterPasswordHintById @@ -108,11 +110,14 @@ import com.artemchep.keyguard.common.usecase.RemoveEmailRelayById import com.artemchep.keyguard.common.usecase.RemoveFolderById import com.artemchep.keyguard.common.usecase.RemoveGeneratorHistory import com.artemchep.keyguard.common.usecase.RemoveGeneratorHistoryById +import com.artemchep.keyguard.common.usecase.RemoveSendById import com.artemchep.keyguard.common.usecase.RemoveUrlOverrideById import com.artemchep.keyguard.common.usecase.RenameFolderById import com.artemchep.keyguard.common.usecase.RestoreCipherById import com.artemchep.keyguard.common.usecase.RetryCipher import com.artemchep.keyguard.common.usecase.RotateDeviceIdUseCase +import com.artemchep.keyguard.common.usecase.SendToolbox +import com.artemchep.keyguard.common.usecase.SendToolboxImpl import com.artemchep.keyguard.common.usecase.SupervisorRead import com.artemchep.keyguard.common.usecase.SyncAll import com.artemchep.keyguard.common.usecase.SyncById @@ -156,6 +161,7 @@ import com.artemchep.keyguard.provider.bitwarden.usecase.AddCipherUsedAutofillHi import com.artemchep.keyguard.provider.bitwarden.usecase.AddCipherUsedPasskeyHistoryImpl import com.artemchep.keyguard.provider.bitwarden.usecase.AddFolderImpl import com.artemchep.keyguard.provider.bitwarden.usecase.AddPasskeyCipherImpl +import com.artemchep.keyguard.provider.bitwarden.usecase.AddSendImpl import com.artemchep.keyguard.provider.bitwarden.usecase.AddUriCipherImpl import com.artemchep.keyguard.provider.bitwarden.usecase.ChangeCipherNameByIdImpl import com.artemchep.keyguard.provider.bitwarden.usecase.ChangeCipherPasswordByIdImpl @@ -196,6 +202,7 @@ import com.artemchep.keyguard.provider.bitwarden.usecase.GetUrlOverridesImpl import com.artemchep.keyguard.provider.bitwarden.usecase.GetWordlistPrimitiveImpl import com.artemchep.keyguard.provider.bitwarden.usecase.MergeFolderByIdImpl import com.artemchep.keyguard.provider.bitwarden.usecase.MoveCipherToFolderByIdImpl +import com.artemchep.keyguard.provider.bitwarden.usecase.PatchSendByIdImpl import com.artemchep.keyguard.provider.bitwarden.usecase.PatchWatchtowerAlertCipherImpl import com.artemchep.keyguard.provider.bitwarden.usecase.PutAccountColorByIdImpl import com.artemchep.keyguard.provider.bitwarden.usecase.PutAccountMasterPasswordHintByIdImpl @@ -208,6 +215,7 @@ import com.artemchep.keyguard.provider.bitwarden.usecase.RemoveCipherByIdImpl import com.artemchep.keyguard.provider.bitwarden.usecase.RemoveWordlistByIdImpl import com.artemchep.keyguard.provider.bitwarden.usecase.RemoveEmailRelayByIdImpl import com.artemchep.keyguard.provider.bitwarden.usecase.RemoveFolderByIdImpl +import com.artemchep.keyguard.provider.bitwarden.usecase.RemoveSendByIdImpl import com.artemchep.keyguard.provider.bitwarden.usecase.RemoveUrlOverrideByIdImpl import com.artemchep.keyguard.provider.bitwarden.usecase.RenameFolderByIdImpl import com.artemchep.keyguard.provider.bitwarden.usecase.RestoreCipherByIdImpl @@ -226,6 +234,7 @@ import com.artemchep.keyguard.provider.bitwarden.usecase.util.ModifyCipherById import com.artemchep.keyguard.provider.bitwarden.usecase.util.ModifyDatabase import com.artemchep.keyguard.provider.bitwarden.usecase.util.ModifyFolderById import com.artemchep.keyguard.provider.bitwarden.usecase.util.ModifyProfileById +import com.artemchep.keyguard.provider.bitwarden.usecase.util.ModifySendById import org.kodein.di.DI import org.kodein.di.bindSingleton import org.kodein.di.instance @@ -341,6 +350,9 @@ fun DI.Builder.createSubDi2( bindSingleton { ModifyCipherById(this) } + bindSingleton { + ModifySendById(this) + } bindSingleton { ModifyFolderById(this) } @@ -365,6 +377,12 @@ fun DI.Builder.createSubDi2( bindSingleton { RemoveCipherByIdImpl(this) } + bindSingleton { + RemoveSendByIdImpl(this) + } + bindSingleton { + PatchSendByIdImpl(this) + } bindSingleton { RemoveFolderByIdImpl(this) } @@ -389,6 +407,9 @@ fun DI.Builder.createSubDi2( bindSingleton { CipherToolboxImpl(this) } + bindSingleton { + SendToolboxImpl(this) + } bindSingleton { CipherUnsecureUrlCheckImpl(this) } @@ -460,6 +481,9 @@ fun DI.Builder.createSubDi2( bindSingleton { AddCipherImpl(this) } + bindSingleton { + AddSendImpl(this) + } bindSingleton { AddCipherOpenedHistoryImpl(this) } diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/core/store/bitwarden/BitwardenOptionalStringNullable.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/core/store/bitwarden/BitwardenOptionalStringNullable.kt new file mode 100644 index 0000000..6237471 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/core/store/bitwarden/BitwardenOptionalStringNullable.kt @@ -0,0 +1,24 @@ +package com.artemchep.keyguard.core.store.bitwarden + +import arrow.optics.optics +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@optics +@Serializable +sealed interface BitwardenOptionalStringNullable { + companion object; + + @optics + @Serializable + @SerialName("some") + data class Some( + val value: String?, + ) : BitwardenOptionalStringNullable { + companion object; + } + + @Serializable + @SerialName("none") + data object None : BitwardenOptionalStringNullable +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/core/store/bitwarden/BitwardenSend.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/core/store/bitwarden/BitwardenSend.kt index 1a2e885..8289d2a 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/core/store/bitwarden/BitwardenSend.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/core/store/bitwarden/BitwardenSend.kt @@ -2,6 +2,7 @@ package com.artemchep.keyguard.core.store.bitwarden import arrow.optics.optics import kotlinx.datetime.Instant +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable @@ -33,6 +34,8 @@ data class BitwardenSend( val type: Type = Type.None, val file: File? = null, val text: Text? = null, + // changes + val changes: Changes? = null, ) : BitwardenService.Has { companion object; @@ -45,6 +48,15 @@ data class BitwardenSend( Text, } + @optics + @Serializable + @SerialName("changes") + data class Changes( + val passwordBase64: BitwardenOptionalStringNullable = BitwardenOptionalStringNullable.None, + ) { + companion object; + } + // // Types // diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/add/AddScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/add/AddScreen.kt new file mode 100644 index 0000000..8bf09f6 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/add/AddScreen.kt @@ -0,0 +1,1605 @@ +package com.artemchep.keyguard.feature.add + +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActionScope +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material.icons.outlined.CloudDone +import androidx.compose.material.icons.outlined.FileUpload +import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material.icons.outlined.Password +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +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 +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.artemchep.keyguard.common.model.Loadable +import com.artemchep.keyguard.common.model.UsernameVariationIcon +import com.artemchep.keyguard.common.model.fold +import com.artemchep.keyguard.common.model.titleH +import com.artemchep.keyguard.common.service.logging.LogRepository +import com.artemchep.keyguard.feature.auth.common.TextFieldModel2 +import com.artemchep.keyguard.feature.auth.common.VisibilityState +import com.artemchep.keyguard.feature.auth.common.VisibilityToggle +import com.artemchep.keyguard.feature.home.vault.add.AddState +import com.artemchep.keyguard.feature.home.vault.component.FlatItemTextContent2 +import com.artemchep.keyguard.feature.home.vault.component.Section +import com.artemchep.keyguard.feature.home.vault.component.VaultViewTotpBadge2 +import com.artemchep.keyguard.feature.navigation.LocalNavigationController +import com.artemchep.keyguard.feature.navigation.NavigationIntent +import com.artemchep.keyguard.feature.qr.ScanQrButton +import com.artemchep.keyguard.res.Res +import com.artemchep.keyguard.ui.AutofillButton +import com.artemchep.keyguard.ui.BiFlatContainer +import com.artemchep.keyguard.ui.BiFlatTextField +import com.artemchep.keyguard.ui.BiFlatTextFieldLabel +import com.artemchep.keyguard.ui.BiFlatValueHeightMin +import com.artemchep.keyguard.ui.ContextItem +import com.artemchep.keyguard.ui.DefaultEmphasisAlpha +import com.artemchep.keyguard.ui.DisabledEmphasisAlpha +import com.artemchep.keyguard.ui.DropdownMenuItemFlat +import com.artemchep.keyguard.ui.DropdownMinWidth +import com.artemchep.keyguard.ui.DropdownScopeImpl +import com.artemchep.keyguard.ui.EmailFlatTextField +import com.artemchep.keyguard.ui.ExpandedIfNotEmpty +import com.artemchep.keyguard.ui.ExpandedIfNotEmptyForRow +import com.artemchep.keyguard.ui.FlatDropdown +import com.artemchep.keyguard.ui.FlatItem +import com.artemchep.keyguard.ui.FlatItemLayout +import com.artemchep.keyguard.ui.FlatItemTextContent +import com.artemchep.keyguard.ui.FlatSimpleNote +import com.artemchep.keyguard.ui.FlatTextField +import com.artemchep.keyguard.ui.FlatTextFieldBadge +import com.artemchep.keyguard.ui.LeMOdelBottomSheet +import com.artemchep.keyguard.ui.MediumEmphasisAlpha +import com.artemchep.keyguard.ui.OptionsButton +import com.artemchep.keyguard.ui.PasswordFlatTextField +import com.artemchep.keyguard.ui.PasswordPwnedBadge +import com.artemchep.keyguard.ui.PasswordStrengthBadge +import com.artemchep.keyguard.ui.UrlFlatTextField +import com.artemchep.keyguard.ui.buildContextItems +import com.artemchep.keyguard.ui.focus.focusRequester2 +import com.artemchep.keyguard.ui.icons.IconBox +import com.artemchep.keyguard.ui.icons.KeyguardAttachment +import com.artemchep.keyguard.ui.icons.KeyguardCollection +import com.artemchep.keyguard.ui.icons.KeyguardOrganization +import com.artemchep.keyguard.ui.icons.KeyguardTwoFa +import com.artemchep.keyguard.ui.icons.KeyguardWebsite +import com.artemchep.keyguard.ui.icons.icon +import com.artemchep.keyguard.ui.markdown.MarkdownText +import com.artemchep.keyguard.ui.shimmer.shimmer +import com.artemchep.keyguard.ui.skeleton.SkeletonText +import com.artemchep.keyguard.ui.skeleton.SkeletonTextField +import com.artemchep.keyguard.ui.text.annotatedResource +import com.artemchep.keyguard.ui.theme.Dimens +import com.artemchep.keyguard.ui.theme.combineAlpha +import com.artemchep.keyguard.ui.theme.isDark +import com.artemchep.keyguard.ui.theme.monoFontFamily +import com.artemchep.keyguard.ui.util.DividerColor +import com.artemchep.keyguard.ui.util.HorizontalDivider +import com.artemchep.keyguard.ui.util.VerticalDivider +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.collections.immutable.ImmutableList + +context(AddScreenScope) +@Composable +fun ColumnScope.AddScreenItems( +) { + SkeletonTextField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimens.horizontalPadding), + ) + SkeletonTextField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Dimens.horizontalPadding), + ) + SkeletonText( + modifier = Modifier + .fillMaxWidth(0.75f) + .padding(horizontal = Dimens.horizontalPadding), + style = MaterialTheme.typography.labelMedium, + ) + SkeletonText( + modifier = Modifier + .fillMaxWidth(0.4f) + .padding(horizontal = Dimens.horizontalPadding), + style = MaterialTheme.typography.labelMedium, + ) +} + +context(AddScreenScope) +@Composable +fun ColumnScope.AddScreenItems( + items: List, +) { + items.forEach { + key(it.id) { + AnyField( + modifier = Modifier, + item = it, + ) + } + } +} + +context(AddScreenScope) +@Composable +private fun AnyField( + modifier: Modifier = Modifier, + item: AddStateItem, +) = when (item) { + is AddStateItem.Title<*> -> { + TitleTextField( + modifier = modifier, + item = item, + ) + } + + is AddStateItem.Username<*> -> { + UsernameTextField( + modifier = modifier, + item = item, + ) + } + + is AddStateItem.Password<*> -> { + PasswordTextField( + modifier = modifier, + item = item, + ) + } + + is AddStateItem.Totp<*> -> { + TotpTextField( + modifier = modifier, + item = item, + ) + } + + is AddStateItem.Url<*> -> { + UrlTextField( + modifier = modifier, + item = item, + ) + } + + is AddStateItem.Attachment<*> -> { + AttachmentTextField( + modifier = modifier, + item = item, + ) + } + + is AddStateItem.Passkey<*> -> { + PasskeyField( + modifier = modifier, + item = item, + ) + } + + is AddStateItem.Note<*> -> { + NoteTextField( + modifier = modifier, + item = item, + ) + } + + is AddStateItem.Text<*> -> { + TextTextField( + modifier = modifier, + item = item, + ) + } + + is AddStateItem.DateMonthYear<*> -> { + DateMonthYearField( + modifier = modifier, + item = item, + ) + } + + is AddStateItem.DateTime<*> -> { + DateTimeField( + modifier = modifier, + item = item, + ) + } + + is AddStateItem.Switch<*> -> { + SwitchField( + modifier = modifier, + item = item, + ) + } + + is AddStateItem.Suggestion<*> -> { + SuggestionItem( + modifier = modifier, + item = item, + ) + } + + is AddStateItem.Enum<*> -> { + EnumItem( + modifier = modifier, + item = item, + ) + } + + is AddStateItem.Field<*> -> { + FieldField( + modifier = modifier, + item = item, + ) + } + + is AddStateItem.Section -> { + SectionItem( + modifier = modifier, + item = item, + ) + } + + is AddStateItem.Add -> { + AddItem( + modifier = modifier, + item = item, + ) + } +} + +context(AddScreenScope) +@Composable +private fun TitleTextField( + modifier: Modifier = Modifier, + item: AddStateItem.Title<*>, +) { + val field by item.state.flow.collectAsState() + + val keyboardOnNext: KeyboardActionScope.() -> Unit = run { + val updatedFocusManager by rememberUpdatedState(LocalFocusManager.current) + remember { + // lambda + { + updatedFocusManager.moveFocus(FocusDirection.Down) + } + } + } + val focusRequester = initialFocusRequesterEffect() + FlatTextField( + modifier = modifier + .padding(horizontal = Dimens.horizontalPadding), + fieldModifier = Modifier + .focusRequester2(focusRequester), + label = stringResource(Res.strings.generic_name), + singleLine = true, + value = field, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions( + onNext = keyboardOnNext, + ), + ) +} + +context(AddScreenScope) +@Composable +private fun UsernameTextField( + modifier: Modifier = Modifier, + item: AddStateItem.Username<*>, +) { + val state by item.state.flow.collectAsState() + val field = state.value + EmailFlatTextField( + modifier = modifier + .padding(horizontal = Dimens.horizontalPadding), + label = stringResource(Res.strings.username), + value = field, + leading = { + Crossfade( + targetState = state.type, + ) { variation -> + UsernameVariationIcon( + usernameVariation = variation, + ) + } + }, + trailing = { + AutofillButton( + key = "username", + username = true, + onValueChange = field.onChange, + ) + }, + ) +} + +context(AddScreenScope) +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun PasswordTextField( + modifier: Modifier = Modifier, + item: AddStateItem.Password<*>, +) = Column(modifier = modifier) { + val field by item.state.flow.collectAsState() + PasswordFlatTextField( + modifier = Modifier + .padding(horizontal = Dimens.horizontalPadding), + value = field, + leading = { + IconBox( + main = Icons.Outlined.Password, + ) + }, + trailing = { + AutofillButton( + key = "password", + password = true, + onValueChange = field.onChange, + ) + }, + content = { + ExpandedIfNotEmpty( + valueOrNull = Unit.takeIf { field.state.value.isNotEmpty() && field.error == null }, + ) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding( + top = 8.dp, + bottom = 8.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + PasswordStrengthBadge( + password = field.state.value, + ) + PasswordPwnedBadge( + password = field.state.value, + ) + } + } + }, + ) + Text( + modifier = Modifier + .padding(horizontal = Dimens.horizontalPadding) + .padding( + top = Dimens.topPaddingCaption, + bottom = Dimens.topPaddingCaption, + ), + style = MaterialTheme.typography.bodyMedium, + text = stringResource(Res.strings.generator_password_note, 16), + color = LocalContentColor.current + .combineAlpha(alpha = MediumEmphasisAlpha), + ) +} + +context(AddScreenScope) +@Composable +private fun TotpTextField( + modifier: Modifier = Modifier, + item: AddStateItem.Totp<*>, +) = Column(modifier = modifier) { + val state by item.state.flow.collectAsState() + val field = state.value + FlatTextField( + modifier = modifier + .padding(horizontal = Dimens.horizontalPadding), + label = stringResource(Res.strings.one_time_password_authenticator_key), + value = field, + textStyle = LocalTextStyle.current.copy( + fontFamily = monoFontFamily, + ), + singleLine = true, + maxLines = 1, + trailing = { + ScanQrButton( + onValueChange = state.value.onChange, + ) + }, + leading = { + IconBox( + main = Icons.Outlined.KeyguardTwoFa, + ) + }, + ) + ExpandedIfNotEmpty( + modifier = Modifier + .fillMaxWidth(), + valueOrNull = state.totpToken, + ) { totpToken -> + Box { + VaultViewTotpBadge2( + modifier = Modifier + .padding(top = 8.dp) + .padding(start = 16.dp), + copyText = state.copyText, + totpToken = totpToken, + ) + } + } +} + +context(AddScreenScope) +@Composable +private fun NoteTextField( + modifier: Modifier = Modifier, + item: AddStateItem.Note<*>, +) = Column( + modifier = modifier, +) { + val field by item.state.flow.collectAsState() + val markdown = item.markdown + + var isMarkdownRenderPopupShown by remember { + mutableStateOf(false) + } + if (!markdown) { + isMarkdownRenderPopupShown = false + } + + FlatTextField( + modifier = Modifier + .padding(horizontal = Dimens.horizontalPadding), + label = stringResource(Res.strings.note), + value = field, + content = if (markdown) { + // composable + { + ExpandedIfNotEmpty( + Unit.takeIf { field.text.isNotEmpty() }, + ) { + TextButton( + onClick = { + isMarkdownRenderPopupShown = true + }, + ) { + Text( + text = stringResource(Res.strings.additem_markdown_render_preview), + ) + } + } + } + } else { + null + }, + // TODO: Long note moves a lot when you focus then field, because + // the clear button suddenly appears. We might move the button + // to the footer or something to keep the functionality. + clearButton = false, + ) + if (markdown) { + val description = annotatedResource( + Res.strings.additem_markdown_note, + stringResource(Res.strings.additem_markdown_note_italic) + .let { "*$it*" } to SpanStyle( + fontStyle = FontStyle.Italic, + ), + stringResource(Res.strings.additem_markdown_note_bold) + .let { "**$it**" } to SpanStyle( + fontWeight = FontWeight.Bold, + ), + ) + Row( + modifier = Modifier + .padding(horizontal = Dimens.horizontalPadding) + .padding(top = Dimens.topPaddingCaption), + ) { + val navigationController = LocalNavigationController.current + Text( + modifier = Modifier + .weight(1f), + text = description, + style = MaterialTheme.typography.bodyMedium, + color = LocalContentColor.current.combineAlpha(alpha = MediumEmphasisAlpha), + ) + Spacer( + modifier = Modifier + .width(16.dp), + ) + TextButton( + onClick = { + val intent = NavigationIntent.NavigateToBrowser( + url = "https://www.markdownguide.org/basic-syntax/", + ) + navigationController.queue(intent) + }, + ) { + Text( + text = stringResource(Res.strings.learn_more), + ) + } + } + } + // Inject the dropdown popup to the bottom of the + // content. + val onHideMarkdownRenderPopupRequest = remember { + // lambda + { + isMarkdownRenderPopupShown = false + } + } + LeMOdelBottomSheet( + visible = isMarkdownRenderPopupShown, + onDismissRequest = onHideMarkdownRenderPopupRequest, + ) { contentPadding -> + val verticalScrollState = rememberScrollState() + Column( + modifier = Modifier + .verticalScroll(verticalScrollState) + .padding(contentPadding), + ) { + Text( + modifier = Modifier + .padding( + horizontal = Dimens.horizontalPadding, + vertical = 8.dp, + ), + text = "Markdown", + style = MaterialTheme.typography.titleLarge, + ) + MarkdownText( + modifier = Modifier + .padding( + horizontal = Dimens.horizontalPadding, + vertical = Dimens.verticalPadding, + ), + markdown = field.text, + ) + } + } +} + +context(AddScreenScope) +@Composable +private fun UrlTextField( + modifier: Modifier = Modifier, + item: AddStateItem.Url<*>, +) { + val state by item.state.flow.collectAsState() + val field = state.text + UrlFlatTextField( + modifier = modifier + .padding(horizontal = Dimens.horizontalPadding), + label = stringResource(Res.strings.uri), + value = field, + leading = { + IconBox( + main = Icons.Outlined.KeyguardWebsite, + ) + }, + trailing = { + val actions = remember( + state.options, + item.options, + ) { + buildContextItems( + state.options, + item.options, + ) { + // Do nothing. + } + } + OptionsButton( + actions = actions, + ) + }, + content = { + ExpandedIfNotEmpty( + valueOrNull = state.matchTypeTitle, + ) { matchType -> + FlatTextFieldBadge( + type = TextFieldModel2.Vl.Type.INFO, + text = matchType, + ) + } + }, + ) +} + +context(AddScreenScope) +@Composable +private fun AttachmentTextField( + modifier: Modifier = Modifier, + item: AddStateItem.Attachment<*>, +) { + val state by item.state.flow.collectAsState() + FlatTextField( + modifier = modifier + .padding(horizontal = Dimens.horizontalPadding), + label = "File", + placeholder = "File name", + value = state.name, + keyboardOptions = KeyboardOptions( + autoCorrect = false, + keyboardType = KeyboardType.Text, + ), + singleLine = true, + maxLines = 1, + leading = { + Box { + Icon( + imageVector = Icons.Outlined.KeyguardAttachment, + contentDescription = null, + ) + Row( + modifier = Modifier + .align(Alignment.BottomEnd) + .background( + MaterialTheme.colorScheme.tertiaryContainer, + MaterialTheme.shapes.extraSmall, + ), + ) { + if (state.synced) { + Icon( + modifier = Modifier + .size(16.dp) + .padding(1.dp), + imageVector = Icons.Outlined.CloudDone, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + contentDescription = null, + ) + } + if (!state.synced) { + Icon( + modifier = Modifier + .size(16.dp) + .padding(1.dp), + imageVector = Icons.Outlined.FileUpload, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + contentDescription = null, + ) + } + } + } + }, + content = { + ExpandedIfNotEmpty( + valueOrNull = state.size, + ) { fileSize -> + Column { + Spacer(Modifier.height(8.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + Text( + text = fileSize, + style = MaterialTheme.typography.labelSmall, + ) + } + } + }, + trailing = { + OptionsButton( + actions = item.options, + ) + }, + ) +} + +context(AddScreenScope) +@Composable +private fun PasskeyField( + modifier: Modifier = Modifier, + item: AddStateItem.Passkey<*>, +) { + val state by item.state.flow.collectAsState() + FlatItemLayout( + modifier = modifier, + leading = icon(Icons.Outlined.Key), + contentPadding = PaddingValues( + horizontal = 16.dp, + vertical = 8.dp, + ), + content = { + val passkey = state.passkey + if (passkey != null) { + FlatItemTextContent( + title = { + Text( + text = passkey.userDisplayName.orEmpty(), + ) + }, + text = { + Text( + text = passkey.rpName, + ) + }, + ) + } else { + FlatItemTextContent( + title = { + Text( + text = stringResource(Res.strings.passkey), + ) + }, + ) + } + }, + trailing = { + OptionsButton( + actions = item.options, + ) + }, + enabled = true, + ) +} + +context(AddScreenScope) +@Composable +private fun TextTextField( + modifier: Modifier = Modifier, + item: AddStateItem.Text<*>, +) { + val state by item.state.flow.collectAsState() + val field = state.value + FlatTextField( + modifier = modifier + .padding(horizontal = Dimens.horizontalPadding), + label = state.label, + value = field, + singleLine = state.singleLine, + keyboardOptions = state.keyboardOptions, + visualTransformation = state.visualTransformation, + ) +} + +context(AddScreenScope) +@Composable +private fun FieldField( + modifier: Modifier = Modifier, + item: AddStateItem.Field<*>, +) = Column(modifier = modifier) { + val state by item.state.flow.collectAsState() + val actions = remember( + state.options, + item.options, + ) { + buildContextItems( + state.options, + item.options, + ) { + // Do nothing. + } + } + when (val s = state) { + is AddStateItem.Field.State.Text -> { + FieldTextField( + state = s, + actions = actions, + ) + } + + is AddStateItem.Field.State.Switch -> { + FieldSwitchField( + state = s, + actions = actions, + ) + } + + is AddStateItem.Field.State.LinkedId -> { + FieldLinkedIdField( + state = s, + actions = actions, + ) + } + } +} + +context(AddScreenScope) +@Composable +private fun FieldTextField( + modifier: Modifier = Modifier, + state: AddStateItem.Field.State.Text, + actions: ImmutableList, +) { + val visibilityState = remember(state.hidden) { + VisibilityState( + isVisible = !state.hidden, + ) + } + BiFlatTextField( + modifier = modifier + .padding(horizontal = Dimens.horizontalPadding), + label = state.label, + value = state.text, + valueVisualTransformation = if (visibilityState.isVisible || !state.hidden) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailing = { + ExpandedIfNotEmptyForRow( + valueOrNull = Unit.takeIf { state.hidden }, + ) { + VisibilityToggle( + visibilityState = visibilityState, + ) + } + OptionsButton( + actions = actions, + ) + }, + ) +} + +context(AddScreenScope) +@Composable +private fun FieldSwitchField( + modifier: Modifier = Modifier, + state: AddStateItem.Field.State.Switch, + actions: ImmutableList, +) { + FlatTextField( + modifier = modifier + .padding(horizontal = Dimens.horizontalPadding), + value = state.label, + trailing = { + Checkbox( + checked = state.checked, + onCheckedChange = state.onCheckedChange, + ) + OptionsButton( + actions = actions, + ) + }, + ) +} + +context(AddScreenScope) +@Composable +private fun FieldLinkedIdField( + modifier: Modifier = Modifier, + state: AddStateItem.Field.State.LinkedId, + actions: ImmutableList, +) { + val label = state.label + + val labelInteractionSource = remember { MutableInteractionSource() } + val valueInteractionSource = remember { MutableInteractionSource() } + + val isError = remember( + label.error, + ) { + derivedStateOf { + label.error != null + } + } + + val hasFocusState = remember { + mutableStateOf(false) + } + + val isEmpty = remember( + label.state, + ) { + derivedStateOf { + label.state.value.isBlank() + } + } + + val dropdownShownState = remember { mutableStateOf(false) } + if (state.actions.isEmpty()) { + dropdownShownState.value = false + } + + // Inject the dropdown popup to the bottom of the + // content. + val onDismissRequest = remember(dropdownShownState) { + // lambda + { + dropdownShownState.value = false + } + } + + BiFlatContainer( + modifier = modifier + .padding(horizontal = Dimens.horizontalPadding) + .onFocusChanged { state -> + hasFocusState.value = state.hasFocus + }, + contentModifier = Modifier + .clickable( + indication = LocalIndication.current, + interactionSource = valueInteractionSource, + role = Role.Button, + ) { + dropdownShownState.value = true + }, + isError = isError, + isFocused = hasFocusState, + isEmpty = isEmpty, + label = { + BiFlatTextFieldLabel( + label = label, + interactionSource = labelInteractionSource, + ) + }, + content = { + Row { + val textColor = + if (state.value != null) { + LocalContentColor.current + } else { + LocalContentColor.current + .combineAlpha(MediumEmphasisAlpha) + } + Text( + modifier = Modifier + .heightIn(min = BiFlatValueHeightMin) + .weight(1f, fill = false), + text = (state.value?.titleH() ?: Res.strings.select_linked_type) + .let { stringResource(it) }, + color = textColor, + ) + Spacer( + modifier = Modifier + .width(4.dp), + ) + Icon( + modifier = Modifier + .alpha(MediumEmphasisAlpha), + imageVector = Icons.Outlined.ArrowDropDown, + contentDescription = null, + ) + } + + DropdownMenu( + modifier = Modifier + .widthIn(min = DropdownMinWidth), + expanded = dropdownShownState.value, + onDismissRequest = onDismissRequest, + ) { + val scope = DropdownScopeImpl(this, onDismissRequest = onDismissRequest) + with(scope) { + state.actions.forEachIndexed { index, action -> + DropdownMenuItemFlat( + action = action, + ) + } + } + } + }, + trailing = { + OptionsButton( + actions = actions, + ) + }, + ) +} + +context(AddScreenScope) +@Composable +private fun SwitchField( + modifier: Modifier = Modifier, + item: AddStateItem.Switch<*>, +) { + val state by item.state.flow.collectAsState() + val onClick: () -> Unit = remember(item.state.flow) { + // lambda + { + val field = item.state.flow.value + field.onChange?.invoke(!field.checked) + } + } + FlatItem( + modifier = modifier, + leading = { + Checkbox( + checked = state.checked, + enabled = state.onChange != null, + onCheckedChange = null, + ) + }, + title = { + Text( + text = item.title, + ) + }, + text = if (item.text != null) { + // composable + { + Text( + text = item.text, + ) + } + } else { + null + }, + onClick = onClick, + ) +} + +context(AddScreenScope) +@Composable +private fun DateMonthYearField( + modifier: Modifier = Modifier, + item: AddStateItem.DateMonthYear<*>, +) { + val state by item.state.flow.collectAsState() + BiFlatContainer( + modifier = modifier + .padding(horizontal = Dimens.horizontalPadding), + contentModifier = Modifier + .clickable( + indication = LocalIndication.current, + interactionSource = remember { + MutableInteractionSource() + }, + role = Role.Button, + ) { + state.onClick.invoke() + }, + isError = rememberUpdatedState(newValue = false), + isFocused = rememberUpdatedState(newValue = false), + isEmpty = rememberUpdatedState(newValue = false), + label = { + Text( + text = item.label, + style = MaterialTheme.typography.bodySmall, + ) + }, + content = { + val density = LocalDensity.current + Row( + modifier = Modifier + .graphicsLayer { + translationY = 12f + density.density + }, + ) { + Row( + modifier = Modifier + .heightIn(min = BiFlatValueHeightMin) + .weight(1f, fill = false), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + val month = state.month.text.ifBlank { "--" } + val year = state.year.text.ifBlank { "----" } + Text( + text = month, + ) + Text( + text = "/", + color = LocalContentColor.current + .combineAlpha(DisabledEmphasisAlpha), + ) + Text( + text = year, + ) + } +// Spacer( +// modifier = Modifier +// .width(4.dp), +// ) +// Icon( +// modifier = Modifier +// .alpha(MediumEmphasisAlpha), +// imageVector = Icons.Outlined.ArrowDropDown, +// contentDescription = null, +// ) + } + }, + trailing = { + val isEmpty = state.month.state.value.isEmpty() && + state.year.state.value.isEmpty() + ExpandedIfNotEmptyForRow( + valueOrNull = Unit.takeUnless { isEmpty }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer( + modifier = Modifier + .width(8.dp), + ) + VerticalDivider( + modifier = Modifier + .height(24.dp), + ) + Spacer( + modifier = Modifier + .width(8.dp), + ) + IconButton( + enabled = true, + onClick = { + state.month.state.value = "" + state.year.state.value = "" + }, + ) { + Icon( + imageVector = Icons.Outlined.Clear, + contentDescription = null, + ) + } + } + } + }, + ) +} + +context(AddScreenScope) +@Composable +private fun DateTimeField( + modifier: Modifier = Modifier, + item: AddStateItem.DateTime<*>, +) { + val state by item.state.flow.collectAsState() + Column( + modifier = modifier, + ) { + + } +} + +context(AddScreenScope) +@Composable +private fun EnumItem( + modifier: Modifier = Modifier, + item: AddStateItem.Enum<*>, +) { + val state by item.state.flow.collectAsState() + FlatDropdown( + modifier = modifier, + content = { + FlatItemTextContent2( + title = { + Text(item.label) + }, + text = { + Text(state.value) + }, + ) + }, + leading = { + Icon( + imageVector = item.icon, + contentDescription = null, + ) + }, + dropdown = state.dropdown, + ) +} + +context(AddScreenScope) +@Composable +private fun SectionItem( + modifier: Modifier = Modifier, + item: AddStateItem.Section, +) { + Section( + modifier = modifier, + text = item.text, + ) +} + +context(AddScreenScope) +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SuggestionItem( + modifier: Modifier = Modifier, + item: AddStateItem.Suggestion<*>, +) { + val state by item.state.flow.collectAsState() + FlowRow( + modifier = modifier + .padding(horizontal = Dimens.horizontalPadding), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + state.items.forEach { item -> + key(item.key) { + SuggestionItemChip( + item = item, + ) + } + } + } +} + +@Composable +private fun SuggestionItemChip( + modifier: Modifier = Modifier, + item: AddStateItem.Suggestion.Item, +) { + val backgroundColor = + if (item.selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface + Surface( + modifier = modifier, + border = if (item.selected) null else BorderStroke(0.dp, DividerColor), + color = backgroundColor, + shape = MaterialTheme.shapes.small, + ) { + val contentColor = LocalContentColor.current + .let { color -> + if (item.onClick != null) { + color // enabled + } else { + color.combineAlpha(DisabledEmphasisAlpha) + } + } + CompositionLocalProvider( + LocalContentColor provides contentColor, + ) { + Column( + modifier = Modifier + .then( + if (item.onClick != null) { + Modifier + .clickable(role = Role.Button) { + item.onClick.invoke() + } + } else { + Modifier + }, + ) + .padding( + horizontal = 12.dp, + vertical = 8.dp, + ), + ) { + Text( + text = item.text, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = item.source, + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current + .combineAlpha(MediumEmphasisAlpha), + ) + } + } + } +} + +context(AddScreenScope) +@Composable +private fun AddItem( + modifier: Modifier = Modifier, + item: AddStateItem.Add, +) = Column( + modifier = modifier, +) { + val dropdownShownState = remember { mutableStateOf(false) } + if (item.actions.isEmpty()) { + dropdownShownState.value = false + } + + // Inject the dropdown popup to the bottom of the + // content. + val onDismissRequest = remember(dropdownShownState) { + // lambda + { + dropdownShownState.value = false + } + } + val contentColor = MaterialTheme.colorScheme.primary + FlatItem( + leading = { + Icon(Icons.Outlined.Add, null, tint = contentColor) + }, + title = { + Text( + text = item.text, + color = contentColor, + ) + + DropdownMenu( + modifier = Modifier + .widthIn(min = DropdownMinWidth), + expanded = dropdownShownState.value, + onDismissRequest = onDismissRequest, + ) { + val scope = DropdownScopeImpl(this, onDismissRequest = onDismissRequest) + with(scope) { + item.actions.forEachIndexed { index, action -> + DropdownMenuItemFlat( + action = action, + ) + } + } + } + }, + onClick = { + if (item.actions.size == 1) { + // TODO: !!!!!!!!! + // item.actions.first() + // .onClick?.invoke() + } else { + dropdownShownState.value = true + } + }, + ) +} + +// +// Ownership +// + +@Composable +private fun ToolbarContentItem( + element: AddStateOwnership.Element, +) { + element.items.forEach { + key(it.key) { + Column( + modifier = Modifier + .heightIn(min = 32.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = it.title, + style = MaterialTheme.typography.bodyMedium, + ) + if (it.text != null) { + Text( + text = it.text, + color = LocalContentColor.current + .combineAlpha(MediumEmphasisAlpha), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } +} + +@Composable +private fun ToolbarContentItemErr( + modifier: Modifier = Modifier, + icon: ImageVector, + element: AddStateOwnership.Element, +) { + ToolbarContentItemErr( + modifier = modifier, + leading = { + Icon( + imageVector = icon, + contentDescription = null, + ) + }, + element = element, + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ToolbarContentItemErr( + modifier: Modifier = Modifier, + leading: @Composable () -> Unit, + element: AddStateOwnership.Element, +) { + val alpha = if (!element.readOnly) DefaultEmphasisAlpha else DisabledEmphasisAlpha + Row( + modifier = modifier + .heightIn(min = 32.dp) + .alpha(alpha), + ) { + Box( + modifier = Modifier + .size(32.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(16.dp), + ) { + leading() + } + } + Spacer(modifier = Modifier.width(14.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ToolbarContentItem(element = element) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ToolbarContentItemErrSkeleton( + modifier: Modifier = Modifier, + fraction: Float = 1f, +) { + Row( + modifier = modifier + .heightIn(min = 32.dp), + ) { + Box( + modifier = Modifier + .size(32.dp), + contentAlignment = Alignment.Center, + ) { + val contentColor = LocalContentColor.current + .combineAlpha(DisabledEmphasisAlpha) + Box( + modifier = Modifier + .shimmer() + .size(16.dp) + .clip(MaterialTheme.shapes.small) + .background(contentColor), + ) + } + Spacer(modifier = Modifier.width(14.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column( + modifier = Modifier + .heightIn(min = 32.dp), + verticalArrangement = Arrangement.Center, + ) { + SkeletonText( + modifier = Modifier + .fillMaxWidth(fraction), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } +} + +@Composable +fun ToolbarContent( + modifier: Modifier = Modifier, + account: AddStateOwnership.Element? = null, + organization: AddStateOwnership.Element? = null, + collection: AddStateOwnership.Element? = null, + folder: AddStateOwnership.Element? = null, + onClick: (() -> Unit)? = null, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding( + horizontal = 8.dp, + vertical = 2.dp, + ) + .clip(MaterialTheme.shapes.medium) + .then( + if (onClick != null) { + Modifier + .clickable( + role = Role.Button, + onClick = onClick, + ) + } else { + Modifier + }, + ) + .padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(end = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (account != null) { + ToolbarContentItemErr( + modifier = Modifier + .weight(1.5f), + leading = { + val accentColors = account.items.firstOrNull() + ?.accentColors + ?: return@ToolbarContentItemErr + val backgroundColor = if (MaterialTheme.colorScheme.isDark) { + accentColors.dark + } else { + accentColors.light + } + Box( + modifier = Modifier + .size(24.dp) + .background(backgroundColor, CircleShape), + ) + }, + element = account, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + if (folder != null) { + ToolbarContentItemErr( + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + icon = Icons.Outlined.Folder, + element = folder, + ) + } + } + ExpandedIfNotEmpty( + valueOrNull = organization, + ) { org -> + ToolbarContentItemErr( + modifier = Modifier + .padding(end = 8.dp), + icon = Icons.Outlined.KeyguardOrganization, + element = org, + ) + } + ExpandedIfNotEmpty( + valueOrNull = collection, + ) { col -> + ToolbarContentItemErr( + modifier = Modifier + .padding(end = 8.dp), + icon = Icons.Outlined.KeyguardCollection, + element = col, + ) + } + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/add/AddScreenScope.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/add/AddScreenScope.kt new file mode 100644 index 0000000..ecc12b1 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/add/AddScreenScope.kt @@ -0,0 +1,32 @@ +package com.artemchep.keyguard.feature.add + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.focus.FocusRequester +import com.artemchep.keyguard.ui.focus.FocusRequester2 + +class AddScreenScope( + initialFocusRequested: Boolean = false, +) { + val initialFocusRequestedState = mutableStateOf(initialFocusRequested) + + @Composable + fun initialFocusRequesterEffect(): FocusRequester2 { + val focusRequester = remember { FocusRequester2() } + // Auto focus the text field + // on launch. + LaunchedEffect(focusRequester) { + var initialFocusRequested by initialFocusRequestedState + if (!initialFocusRequested) { + focusRequester.requestFocus() + // do not request it the second time + initialFocusRequested = true + } + } + return focusRequester + } +} \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/add/AddStateItem.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/add/AddStateItem.kt new file mode 100644 index 0000000..b9bb2bd --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/add/AddStateItem.kt @@ -0,0 +1,299 @@ +package com.artemchep.keyguard.feature.add + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.input.VisualTransformation +import com.artemchep.keyguard.common.model.DSecret +import com.artemchep.keyguard.common.model.TotpToken +import com.artemchep.keyguard.common.model.UsernameVariation2 +import com.artemchep.keyguard.common.model.create.CreateRequest +import com.artemchep.keyguard.common.usecase.CopyText +import com.artemchep.keyguard.feature.auth.common.SwitchFieldModel +import com.artemchep.keyguard.feature.auth.common.TextFieldModel2 +import com.artemchep.keyguard.ui.ContextItem +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.StateFlow +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime + +sealed interface AddStateItem { + val id: String + + interface HasOptions { + val options: ImmutableList + + /** + * Copies the data class replacing the old options with a + * provided ones. + */ + fun withOptions( + options: ImmutableList, + ): T + } + + interface HasState { + val state: LocalStateItem + } + + @Stable + data class Title( + override val id: String, + override val state: LocalStateItem, + ) : AddStateItem, HasState + + @Stable + data class Username( + override val id: String, + override val state: LocalStateItem, + ) : AddStateItem, HasState { + data class State( + val value: TextFieldModel2, + val type: UsernameVariation2, + ) + } + + @Stable + data class Password( + override val id: String, + override val state: LocalStateItem, + ) : AddStateItem, HasState + + @Stable + data class Text( + override val id: String, + override val state: LocalStateItem, + ) : AddStateItem, HasState { + data class State( + val value: TextFieldModel2, + val label: String? = null, + val singleLine: Boolean = false, + val keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + val visualTransformation: VisualTransformation = VisualTransformation.None, + ) + } + + data class Suggestion( + override val id: String, + override val state: LocalStateItem, + ) : AddStateItem, HasState { + data class State( + val items: ImmutableList, + ) + + data class Item( + val key: String, + val text: String, + val value: String, + val source: String, + val selected: Boolean, + val onClick: (() -> Unit)? = null, + ) + } + + data class Totp( + override val id: String, + override val state: LocalStateItem, + ) : AddStateItem, HasState { + data class State( + val copyText: CopyText, + val value: TextFieldModel2, + val totpToken: TotpToken? = null, + ) + } + + data class Passkey( + override val id: String, + override val options: ImmutableList = persistentListOf(), + override val state: LocalStateItem, + ) : AddStateItem, HasOptions>, HasState { + override fun withOptions( + options: ImmutableList, + ) = copy( + options = options, + ) + + data class State( + val passkey: DSecret.Login.Fido2Credentials?, + ) + } + + data class Attachment( + override val id: String, + override val options: ImmutableList = persistentListOf(), + override val state: LocalStateItem, + ) : AddStateItem, HasOptions>, HasState { + override fun withOptions( + options: ImmutableList, + ) = copy( + options = options, + ) + + data class State( + val id: String, + val name: TextFieldModel2, + val size: String? = null, + /** + * `true` if the attachment is already uploaded to the server, + * `false` otherwise. + */ + val synced: Boolean, + ) + } + + data class Url( + override val id: String, + override val options: ImmutableList = persistentListOf(), + override val state: LocalStateItem, + ) : AddStateItem, HasOptions>, HasState { + override fun withOptions( + options: ImmutableList, + ) = copy( + options = options, + ) + + data class State( + override val options: ImmutableList = persistentListOf(), + val text: TextFieldModel2, + val matchType: DSecret.Uri.MatchType? = null, + val matchTypeTitle: String? = null, + ) : HasOptions { + override fun withOptions( + options: ImmutableList, + ) = copy( + options = options, + ) + } + } + + data class Field( + override val id: String, + override val options: ImmutableList = persistentListOf(), + override val state: LocalStateItem, + ) : AddStateItem, HasOptions>, HasState { + override fun withOptions( + options: ImmutableList, + ) = copy( + options = options, + ) + + sealed interface State : HasOptions { + data class Text( + override val options: ImmutableList = persistentListOf(), + val label: TextFieldModel2, + val text: TextFieldModel2, + val hidden: Boolean = false, + ) : State { + override fun withOptions( + options: ImmutableList, + ) = copy( + options = options, + ) + } + + data class Switch( + override val options: ImmutableList = persistentListOf(), + val checked: Boolean = false, + val onCheckedChange: ((Boolean) -> Unit)? = null, + val label: TextFieldModel2, + ) : State { + override fun withOptions( + options: ImmutableList, + ) = copy( + options = options, + ) + } + + data class LinkedId( + override val options: ImmutableList = persistentListOf(), + val value: DSecret.Field.LinkedId?, + val actions: ImmutableList, + val label: TextFieldModel2, + ) : State { + override fun withOptions( + options: ImmutableList, + ) = copy( + options = options, + ) + } + } + } + + data class Note( + override val id: String, + override val state: LocalStateItem, + val markdown: Boolean, + ) : AddStateItem, HasState + + data class Enum( + override val id: String, + val icon: ImageVector, + val label: String, + override val state: LocalStateItem, + ) : AddStateItem, HasState { + data class State( + val value: String = "", + val dropdown: ImmutableList = persistentListOf(), + ) + } + + data class Switch( + override val id: String, + val title: String, + val text: String? = null, + override val state: LocalStateItem, + ) : AddStateItem, HasState + + data class Section( + override val id: String, + val text: String? = null, + ) : AddStateItem + + // + // CUSTOM + // + + data class DateMonthYear( + override val id: String, + override val state: LocalStateItem, + val label: String, + ) : AddStateItem, HasState { + data class State( + val month: TextFieldModel2, + val year: TextFieldModel2, + val onClick: () -> Unit, + ) + } + + data class DateTime( + override val id: String, + override val state: LocalStateItem, + val label: String, + ) : AddStateItem, HasState { + data class State( + val localDate: LocalDate, + val localTime: LocalTime, + val onSelectDate: () -> Unit, + val onSelectTime: () -> Unit, + ) + } + + // + // Items that may modify the amount of items in the + // list. + // + + data class Add( + override val id: String, + val text: String, + val actions: ImmutableList, + ) : AddStateItem +} + +@Stable +data class LocalStateItem( + val flow: StateFlow, + val populator: Request.(T) -> Request = { this }, +) diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/add/AddStateOwnership.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/add/AddStateOwnership.kt new file mode 100644 index 0000000..cb861f8 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/add/AddStateOwnership.kt @@ -0,0 +1,24 @@ +package com.artemchep.keyguard.feature.add + +import com.artemchep.keyguard.ui.icons.AccentColors + +data class AddStateOwnership( + val account: Element? = null, + val organization: Element? = null, + val collection: Element? = null, + val folder: Element? = null, + val onClick: (() -> Unit)? = null, +) { + data class Element( + val readOnly: Boolean, + val items: List = emptyList(), + ) { + data class Item( + val key: String, + val title: String, + val text: String? = null, + val stub: Boolean = false, + val accentColors: AccentColors? = null, + ) + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/organization/OrganizationConfirmationRoute.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/organization/OrganizationConfirmationRoute.kt index ddb6965..98c233a 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/organization/OrganizationConfirmationRoute.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/organization/OrganizationConfirmationRoute.kt @@ -28,11 +28,21 @@ class OrganizationConfirmationRoute( private const val RAW_RO_ORGANIZATION = 2 private const val RAW_RO_COLLECTION = 4 private const val RAW_RO_FOLDER = 8 + private const val RAW_HIDE_ORGANIZATION = 16 + private const val RAW_HIDE_COLLECTION = 32 + private const val RAW_HIDE_FOLDER = 64 + private const val RAW_PREMIUM_ACCOUNT = 128 const val RO_ACCOUNT = RAW_RO_ACCOUNT const val RO_ORGANIZATION = RAW_RO_ORGANIZATION const val RO_COLLECTION = RAW_RO_COLLECTION const val RO_FOLDER = RAW_RO_FOLDER + + const val HIDE_ORGANIZATION = RAW_RO_ORGANIZATION or RAW_HIDE_ORGANIZATION + const val HIDE_COLLECTION = RAW_RO_COLLECTION or RAW_HIDE_COLLECTION + const val HIDE_FOLDER = RAW_RO_FOLDER or RAW_HIDE_FOLDER + + const val PREMIUM_ACCOUNT = RAW_PREMIUM_ACCOUNT } data class Decor( diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/organization/OrganizationConfirmationState.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/organization/OrganizationConfirmationState.kt index b987407..168d566 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/organization/OrganizationConfirmationState.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/organization/OrganizationConfirmationState.kt @@ -11,9 +11,9 @@ data class OrganizationConfirmationState( ) { data class Content( val accounts: Section, - val organizations: Section, - val collections: Section, - val folders: Section, + val organizations: Section?, + val collections: Section?, + val folders: Section?, val folderNew: TextFieldModel2?, ) { data class Section( diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/organization/OrganizationConfirmationStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/organization/OrganizationConfirmationStateProducer.kt index 06baacc..18f308a 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/organization/OrganizationConfirmationStateProducer.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/confirmation/organization/OrganizationConfirmationStateProducer.kt @@ -38,6 +38,7 @@ import com.artemchep.keyguard.ui.theme.isDark import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.shareIn @@ -54,7 +55,6 @@ fun organizationConfirmationState( organizationConfirmationState( args = args, transmitter = transmitter, - getAccounts = instance(), getProfiles = instance(), getOrganizations = instance(), getCollections = instance(), @@ -192,7 +192,6 @@ private data class FolderVariant( fun organizationConfirmationState( args: OrganizationConfirmationRoute.Args, transmitter: RouteResultTransmitter, - getAccounts: GetAccounts, getProfiles: GetProfiles, getOrganizations: GetOrganizations, getCollections: GetCollections, @@ -211,11 +210,18 @@ fun organizationConfirmationState( val readOnlyFolder = OrganizationConfirmationRoute.Args.RO_FOLDER in args.flags val readOnlyCollections = OrganizationConfirmationRoute.Args.RO_COLLECTION in args.flags + val hideOrganization = OrganizationConfirmationRoute.Args.HIDE_ORGANIZATION in args.flags + val hideFolder = OrganizationConfirmationRoute.Args.HIDE_FOLDER in args.flags + val hideCollections = OrganizationConfirmationRoute.Args.HIDE_COLLECTION in args.flags + + val premiumAccount = OrganizationConfirmationRoute.Args.PREMIUM_ACCOUNT in args.flags + val accountsFlow = getProfiles() .map { profiles -> profiles .map { profile -> - val enabled = profile.accountId() !in args.blacklistedAccountIds + val enabled = profile.accountId() !in args.blacklistedAccountIds && + (!premiumAccount || profile.premium) AccountVariant( accountId = profile.accountId(), name = profile.email, @@ -494,7 +500,11 @@ fun organizationConfirmationState( items = items, ) } - val itemOrganizationsFlow = combine( + val itemOrganizationsFlow = if (hideOrganization) { + // When we hide the organization UI we just display + // a section as null. + flowOf(null) + } else combine( organizationsFlow, selectionFlow .map { it.organizationId to it.accountId } @@ -527,7 +537,11 @@ fun organizationConfirmationState( .takeIf { selectedOrganizationId != null }, ) } - val itemCollectionsFlow = combine( + val itemCollectionsFlow = if (hideCollections) { + // When we hide the collection UI we just display + // a section as null. + flowOf(null) + } else combine( collectionsFlow, selectionFlow .map { @@ -577,7 +591,11 @@ fun organizationConfirmationState( val accountId: String?, ) - val itemFoldersFlow = combine( + val itemFoldersFlow = if (hideFolder) { + // When we hide the collection UI we just display + // a section as null. + flowOf(null) + } else combine( foldersFlow, selectionFlow .map { diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/AddScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/AddScreen.kt index ed1ff65..b8abbc5 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/AddScreen.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/AddScreen.kt @@ -83,6 +83,10 @@ import com.artemchep.keyguard.common.model.fold import com.artemchep.keyguard.common.model.getOrNull import com.artemchep.keyguard.common.model.titleH import com.artemchep.keyguard.common.service.logging.LogRepository +import com.artemchep.keyguard.feature.add.AddScreenItems +import com.artemchep.keyguard.feature.add.AddScreenScope +import com.artemchep.keyguard.feature.add.ToolbarContent +import com.artemchep.keyguard.feature.add.ToolbarContentItemErrSkeleton import com.artemchep.keyguard.feature.auth.common.TextFieldModel2 import com.artemchep.keyguard.feature.auth.common.VisibilityState import com.artemchep.keyguard.feature.auth.common.VisibilityToggle @@ -128,6 +132,7 @@ import com.artemchep.keyguard.ui.ScaffoldColumn import com.artemchep.keyguard.ui.SimpleNote import com.artemchep.keyguard.ui.UrlFlatTextField import com.artemchep.keyguard.ui.button.FavouriteToggleButton +import com.artemchep.keyguard.ui.focus.focusRequester2 import com.artemchep.keyguard.ui.icons.IconBox import com.artemchep.keyguard.ui.icons.KeyguardAttachment import com.artemchep.keyguard.ui.icons.KeyguardCollection @@ -274,28 +279,6 @@ private fun AddScreenContent( } } -private class AddScreenScope( - initialFocusRequested: Boolean = false, -) { - val initialFocusRequestedState = mutableStateOf(initialFocusRequested) - - @Composable - fun initialFocusRequesterEffect(): FocusRequester { - val focusRequester = remember { FocusRequester() } - // Auto focus the text field - // on launch. - LaunchedEffect(focusRequester) { - var initialFocusRequested by initialFocusRequestedState - if (!initialFocusRequested) { - focusRequester.requestFocus() - // do not request it the second time - initialFocusRequested = true - } - } - return focusRequester - } -} - @Composable private fun ColumnScope.populateItems( addScreenScope: AddScreenScope, @@ -355,28 +338,9 @@ private fun ColumnScope.populateItemsSkeleton( } Section() Spacer(Modifier.height(24.dp)) - SkeletonTextField( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = Dimens.horizontalPadding), - ) - SkeletonTextField( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = Dimens.horizontalPadding), - ) - SkeletonText( - modifier = Modifier - .fillMaxWidth(0.75f) - .padding(horizontal = Dimens.horizontalPadding), - style = MaterialTheme.typography.labelMedium, - ) - SkeletonText( - modifier = Modifier - .fillMaxWidth(0.4f) - .padding(horizontal = Dimens.horizontalPadding), - style = MaterialTheme.typography.labelMedium, - ) + with(addScreenScope) { + AddScreenItems() + } } @Composable @@ -386,11 +350,11 @@ private fun ColumnScope.populateItemsContent( ) { ToolbarContent( modifier = Modifier, - account = state.ownership.account, - organization = state.ownership.organization, - collection = state.ownership.collection, - folder = state.ownership.folder, - onClick = state.ownership.onClick, + account = state.ownership.ui.account, + organization = state.ownership.ui.organization, + collection = state.ownership.ui.collection, + folder = state.ownership.ui.folder, + onClick = state.ownership.ui.onClick, ) Section() if (state.merge != null) { @@ -422,1342 +386,9 @@ private fun ColumnScope.populateItemsContent( Section() } Spacer(Modifier.height(24.dp)) - val logRepository by rememberInstance() - remember(state) { - logRepository.post("Foo3", "rendered state ${state.items.size} items") - } - state.items.forEach { - key(it.id) { - AnyField( - modifier = Modifier, - addScreenScope = addScreenScope, - item = it, - ) - } - } -} - -@Composable -private fun AnyField( - modifier: Modifier = Modifier, - addScreenScope: AddScreenScope, - item: AddStateItem, -) { - when (item) { - is AddStateItem.Title -> { - val localState by item.state.flow.collectAsState() - NameTextField( - modifier = modifier, - addScreenScope = addScreenScope, - field = localState, - ) - } - - is AddStateItem.Username -> { - val localState by item.state.flow.collectAsState() - UsernameTextField(modifier, localState) - } - - is AddStateItem.Password -> { - val localState by item.state.flow.collectAsState() - PasswordTextField(modifier, localState) - } - - is AddStateItem.Totp -> { - val localState by item.state.flow.collectAsState() - TotpTextField(modifier, localState) - } - - is AddStateItem.Url -> { - UrlTextField(modifier, item) - } - - is AddStateItem.Attachment -> { - AttachmentTextField(modifier, item) - } - - is AddStateItem.Passkey -> { - PasskeyField(modifier, item) - } - - is AddStateItem.Note -> { - val localState by item.state.flow.collectAsState() - NoteTextField(modifier, localState, markdown = item.markdown) - } - - is AddStateItem.Text -> { - val localState by item.state.flow.collectAsState() - TextTextField(modifier, localState) - } - - is AddStateItem.DateMonthYear -> { - DateMonthYearField( - modifier = modifier, - item = item, - ) - } - - is AddStateItem.Switch -> { - SwitchField( - modifier = modifier, - item = item, - ) - } - - is AddStateItem.Suggestion -> { - SuggestionItem( - modifier = modifier, - item = item, - ) - } - - is AddStateItem.Enum -> { - val localState by item.state.flow.collectAsState() - EnumItem( - modifier = modifier, - icon = item.icon, - label = item.label, - title = localState.value, - dropdown = localState.dropdown, - ) - } - - is AddStateItem.Field -> { - Box( - modifier = modifier, - ) { - val logRepository by rememberInstance() - remember(item) { - logRepository.post("Foo3", "im rendering a field! ${item.id}") - } - val localState = remember { - mutableStateOf(null) - } - LaunchedEffect(item.state.flow) { - item.state.flow - .map { a -> - a.withOptions(a.options + item.options) - } - .onEach { - localState.value = it - logRepository.post("Foo3", "im on emit a field! ${it}") - } - .launchIn(this) - } -// val localState = remember(item.state.flow) { -// item.state.flow -// .map { a -> -// a.withOptions(a.options + item.options) -// } -// .onEach { -// logRepository.post("Foo3", "im on emit a field! ${it}") -// } -// }.collectAsState(null) - when (val l = localState.value) { - is AddStateItem.Field.State.Text -> { - FieldTextField(Modifier, l) - } - - is AddStateItem.Field.State.Switch -> { - FieldSwitchField(Modifier, l) - } - - is AddStateItem.Field.State.LinkedId -> { - FieldLinkedIdField(Modifier, l) - } - - null -> { - // Do nothing. - } - } - } - } - - is AddStateItem.Section -> { - SectionItem(modifier, item.text) - } - // Modifiers - is AddStateItem.Add -> { - Column( - modifier = modifier, - ) { - val dropdownShownState = remember { mutableStateOf(false) } - if (item.actions.isEmpty()) { - dropdownShownState.value = false - } - - // Inject the dropdown popup to the bottom of the - // content. - val onDismissRequest = remember(dropdownShownState) { - // lambda - { - dropdownShownState.value = false - } - } - val contentColor = MaterialTheme.colorScheme.primary - FlatItem( - leading = { - Icon(Icons.Outlined.Add, null, tint = contentColor) - }, - title = { - Text( - text = item.text, - color = contentColor, - ) - - DropdownMenu( - modifier = Modifier - .widthIn(min = DropdownMinWidth), - expanded = dropdownShownState.value, - onDismissRequest = onDismissRequest, - ) { - val scope = DropdownScopeImpl(this, onDismissRequest = onDismissRequest) - with(scope) { - item.actions.forEachIndexed { index, action -> - DropdownMenuItemFlat( - action = action, - ) - } - } - } - }, - onClick = { - if (item.actions.size == 1) { - item.actions.first() - .onClick?.invoke() - } else { - dropdownShownState.value = true - } - }, - ) - } - } - } -} - -@Composable -private fun NameTextField( - modifier: Modifier = Modifier, - addScreenScope: AddScreenScope, - field: TextFieldModel2, -) { - val focusManager = LocalFocusManager.current - val keyboardOnNext: KeyboardActionScope.() -> Unit = { - focusManager.moveFocus(FocusDirection.Down) - } - val focusRequester = addScreenScope.initialFocusRequesterEffect() - FlatTextField( - modifier = modifier - .padding(horizontal = Dimens.horizontalPadding), - fieldModifier = Modifier - .focusRequester(focusRequester), - label = stringResource(Res.strings.generic_name), - singleLine = true, - value = field, - keyboardOptions = KeyboardOptions( - imeAction = ImeAction.Next, - ), - keyboardActions = KeyboardActions( - onNext = keyboardOnNext, - ), - ) -} - -@Composable -private fun UsernameTextField( - modifier: Modifier = Modifier, - field: AddStateItem.Username.State, -) { - EmailFlatTextField( - modifier = modifier - .padding(horizontal = Dimens.horizontalPadding), - label = stringResource(Res.strings.username), - value = field.value, - leading = { - Crossfade( - targetState = field.type, - ) { variation -> - UsernameVariationIcon( - usernameVariation = variation, - ) - } - }, - trailing = { - AutofillButton( - key = "username", - username = true, - onValueChange = { - field.value.onChange?.invoke(it) - }, - ) - }, - ) -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun PasswordTextField( - modifier: Modifier = Modifier, - field: TextFieldModel2, -) = Column { - PasswordFlatTextField( - modifier = modifier - .padding(horizontal = Dimens.horizontalPadding), - value = field, - leading = { - IconBox( - main = Icons.Outlined.Password, - ) - }, - trailing = { - AutofillButton( - key = "password", - password = true, - onValueChange = { - field.onChange?.invoke(it) - }, - ) - }, - content = { - ExpandedIfNotEmpty( - valueOrNull = Unit.takeIf { field.state.value.isNotEmpty() && field.error == null }, - ) { - FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding( - top = 8.dp, - bottom = 8.dp, - ), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - PasswordStrengthBadge( - password = field.state.value, - ) - PasswordPwnedBadge( - password = field.state.value, - ) - } - } - }, - ) - Text( - modifier = Modifier - .padding(horizontal = Dimens.horizontalPadding) - .padding( - top = Dimens.topPaddingCaption, - bottom = Dimens.topPaddingCaption, - ), - style = MaterialTheme.typography.bodyMedium, - text = stringResource(Res.strings.generator_password_note, 16), - color = LocalContentColor.current - .combineAlpha(alpha = MediumEmphasisAlpha), - ) -} - -@Composable -private fun TotpTextField( - modifier: Modifier = Modifier, - state: AddStateItem.Totp.State, -) { - FlatTextField( - modifier = modifier - .padding(horizontal = Dimens.horizontalPadding), - label = stringResource(Res.strings.one_time_password_authenticator_key), - value = state.value, - textStyle = LocalTextStyle.current.copy( - fontFamily = monoFontFamily, - ), - singleLine = true, - maxLines = 1, - trailing = { - ScanQrButton( - onValueChange = state.value.onChange, - ) - }, - leading = { - IconBox( - main = Icons.Outlined.KeyguardTwoFa, - ) - }, - ) - ExpandedIfNotEmpty( - modifier = Modifier - .fillMaxWidth(), - valueOrNull = state.totpToken, - ) { totpToken -> - Box { - VaultViewTotpBadge2( - modifier = Modifier - .padding(top = 8.dp) - .padding(start = 16.dp), - copyText = state.copyText, - totpToken = totpToken, - ) - } - } -} - -@Composable -private fun NoteTextField( - modifier: Modifier = Modifier, - field: TextFieldModel2, - markdown: Boolean, -) = Column( - modifier = modifier, -) { - var isAutofillWindowShowing by remember { - mutableStateOf(false) - } - if (!markdown) { - isAutofillWindowShowing = false - } - - FlatTextField( - modifier = Modifier - .padding(horizontal = Dimens.horizontalPadding), - label = stringResource(Res.strings.note), - value = field, - content = if (markdown) { - // composable - { - ExpandedIfNotEmpty( - Unit.takeIf { field.text.isNotEmpty() }, - ) { - TextButton( - onClick = { - isAutofillWindowShowing = true - }, - ) { - Text( - text = stringResource(Res.strings.additem_markdown_render_preview), - ) - } - } - } - } else { - null - }, - // TODO: Long note moves a lot when you focus then field, because - // the clear button suddenly appears. We might move the button - // to the footer or something to keep the functionality. - clearButton = false, - ) - if (markdown) { - val description = annotatedResource( - Res.strings.additem_markdown_note, - stringResource(Res.strings.additem_markdown_note_italic) - .let { "*$it*" } to SpanStyle( - fontStyle = FontStyle.Italic, - ), - stringResource(Res.strings.additem_markdown_note_bold) - .let { "**$it**" } to SpanStyle( - fontWeight = FontWeight.Bold, - ), - ) - Row( - modifier = Modifier - .padding(horizontal = Dimens.horizontalPadding) - .padding(top = Dimens.topPaddingCaption), - ) { - val navigationController = LocalNavigationController.current - Text( - modifier = Modifier - .weight(1f), - text = description, - style = MaterialTheme.typography.bodyMedium, - color = LocalContentColor.current.combineAlpha(alpha = MediumEmphasisAlpha), - ) - Spacer( - modifier = Modifier - .width(16.dp), - ) - TextButton( - onClick = { - val intent = NavigationIntent.NavigateToBrowser( - url = "https://www.markdownguide.org/basic-syntax/", - ) - navigationController.queue(intent) - }, - ) { - Text( - text = stringResource(Res.strings.learn_more), - ) - } - } - } - - // Inject the dropdown popup to the bottom of the - // content. - val onDismissRequest = { - isAutofillWindowShowing = false - } - LeMOdelBottomSheet( - visible = isAutofillWindowShowing, - onDismissRequest = onDismissRequest, - ) { contentPadding -> - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(contentPadding), - ) { - Text( - modifier = Modifier - .padding( - horizontal = 16.dp, - vertical = 8.dp, - ), - text = "Markdown", - style = MaterialTheme.typography.titleLarge, - ) - MarkdownText( - modifier = Modifier - .padding( - horizontal = Dimens.horizontalPadding, - vertical = Dimens.verticalPadding, - ), - markdown = field.text, - ) - } - } -} - -@Composable -private fun UrlTextField( - modifier: Modifier = Modifier, - item: AddStateItem.Url, -) { - val state by item.state.flow.collectAsState() - UrlFlatTextField( - modifier = modifier - .padding(horizontal = Dimens.horizontalPadding), - label = stringResource(Res.strings.uri), - value = state.text, - leading = { - IconBox( - main = Icons.Outlined.KeyguardWebsite, - ) - }, - trailing = { - val actions = remember( - state.options, - item.options, - ) { - state.options + item.options - } - OptionsButton( - actions = actions, - ) - }, - content = { - ExpandedIfNotEmpty( - valueOrNull = state.matchTypeTitle, - ) { matchType -> - FlatTextFieldBadge( - type = TextFieldModel2.Vl.Type.INFO, - text = matchType, - ) - } - }, - ) -} - -@Composable -private fun AttachmentTextField( - modifier: Modifier = Modifier, - item: AddStateItem.Attachment, -) { - val state by item.state.flow.collectAsState() - FlatTextField( - modifier = modifier - .padding(horizontal = Dimens.horizontalPadding), - label = "File", - placeholder = "File name", - value = state.name, - keyboardOptions = KeyboardOptions( - autoCorrect = false, - keyboardType = KeyboardType.Text, - ), - singleLine = true, - maxLines = 1, - leading = { - Box { - Icon( - imageVector = Icons.Outlined.KeyguardAttachment, - contentDescription = null, - ) - Row( - modifier = Modifier - .align(Alignment.BottomEnd) - .background( - MaterialTheme.colorScheme.tertiaryContainer, - MaterialTheme.shapes.extraSmall, - ), - ) { - if (state.synced) { - Icon( - modifier = Modifier - .size(16.dp) - .padding(1.dp), - imageVector = Icons.Outlined.CloudDone, - tint = MaterialTheme.colorScheme.onTertiaryContainer, - contentDescription = null, - ) - } - if (!state.synced) { - Icon( - modifier = Modifier - .size(16.dp) - .padding(1.dp), - imageVector = Icons.Outlined.FileUpload, - tint = MaterialTheme.colorScheme.onTertiaryContainer, - contentDescription = null, - ) - } - } - } - }, - content = { - ExpandedIfNotEmpty( - valueOrNull = state.size, - ) { fileSize -> - Column { - Spacer(Modifier.height(8.dp)) - HorizontalDivider() - Spacer(Modifier.height(8.dp)) - Text( - text = fileSize, - style = MaterialTheme.typography.labelSmall, - ) - } - } - }, - trailing = { - OptionsButton( - actions = item.options, - ) - }, - ) -} - -@Composable -private fun PasskeyField( - modifier: Modifier = Modifier, - item: AddStateItem.Passkey, -) { - val state by item.state.flow.collectAsState() - FlatItemLayout( - modifier = modifier, - leading = icon(Icons.Outlined.Key), - contentPadding = PaddingValues( - horizontal = 16.dp, - vertical = 8.dp, - ), - content = { - val passkey = state.passkey - if (passkey != null) { - FlatItemTextContent( - title = { - Text( - text = passkey.userDisplayName.orEmpty(), - ) - }, - text = { - Text( - text = passkey.rpName, - ) - }, - ) - } else { - FlatItemTextContent( - title = { - Text( - text = stringResource(Res.strings.passkey), - ) - }, - ) - } - }, - trailing = { - OptionsButton( - actions = item.options, - ) - }, - enabled = true, - ) -} - -@Composable -private fun TextTextField( - modifier: Modifier = Modifier, - state: AddStateItem.Text.State, -) { - FlatTextField( - modifier = modifier - .padding(horizontal = Dimens.horizontalPadding), - label = state.label, - value = state.value, - singleLine = state.singleLine, - keyboardOptions = state.keyboardOptions, - visualTransformation = state.visualTransformation, - ) -} - -@Composable -private fun FieldTextField( - modifier: Modifier = Modifier, - field: AddStateItem.Field.State.Text, -) { - val visibilityState = remember(field.hidden) { - VisibilityState( - isVisible = !field.hidden, + with(addScreenScope) { + AddScreenItems( + items = state.items, ) } - BiFlatTextField( - modifier = modifier - .padding(horizontal = Dimens.horizontalPadding), - label = field.label, - value = field.text, - valueVisualTransformation = if (visibilityState.isVisible || !field.hidden) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - trailing = { - ExpandedIfNotEmptyForRow( - valueOrNull = Unit.takeIf { field.hidden }, - ) { - VisibilityToggle( - visibilityState = visibilityState, - ) - } - OptionsButton( - actions = field.options, - ) - }, - ) -} - -@Composable -private fun FieldSwitchField( - modifier: Modifier = Modifier, - field: AddStateItem.Field.State.Switch, -) { - FlatTextField( - modifier = modifier - .padding(horizontal = Dimens.horizontalPadding), - value = field.label, - trailing = { - Checkbox( - checked = field.checked, - onCheckedChange = field.onCheckedChange, - ) - OptionsButton( - actions = field.options, - ) - }, - ) -} - -@Composable -private fun FieldLinkedIdField( - modifier: Modifier = Modifier, - field: AddStateItem.Field.State.LinkedId, -) { - val label = field.label - - val labelInteractionSource = remember { MutableInteractionSource() } - val valueInteractionSource = remember { MutableInteractionSource() } - - val isError = remember( - label.error, - ) { - derivedStateOf { - label.error != null - } - } - - val hasFocusState = remember { - mutableStateOf(false) - } - - val isEmpty = remember( - label.state, - ) { - derivedStateOf { - label.state.value.isBlank() - } - } - - val dropdownShownState = remember { mutableStateOf(false) } - if (field.actions.isEmpty()) { - dropdownShownState.value = false - } - - // Inject the dropdown popup to the bottom of the - // content. - val onDismissRequest = remember(dropdownShownState) { - // lambda - { - dropdownShownState.value = false - } - } - - BiFlatContainer( - modifier = modifier - .padding(horizontal = Dimens.horizontalPadding) - .onFocusChanged { state -> - hasFocusState.value = state.hasFocus - }, - contentModifier = Modifier - .clickable( - indication = LocalIndication.current, - interactionSource = valueInteractionSource, - role = Role.Button, - ) { - dropdownShownState.value = true - }, - isError = isError, - isFocused = hasFocusState, - isEmpty = isEmpty, - label = { - BiFlatTextFieldLabel( - label = label, - interactionSource = labelInteractionSource, - ) - }, - content = { - Row { - val textColor = - if (field.value != null) { - LocalContentColor.current - } else { - LocalContentColor.current - .combineAlpha(MediumEmphasisAlpha) - } - Text( - modifier = Modifier - .heightIn(min = BiFlatValueHeightMin) - .weight(1f, fill = false), - text = (field.value?.titleH() ?: Res.strings.select_linked_type) - .let { stringResource(it) }, - color = textColor, - ) - Spacer( - modifier = Modifier - .width(4.dp), - ) - Icon( - modifier = Modifier - .alpha(MediumEmphasisAlpha), - imageVector = Icons.Outlined.ArrowDropDown, - contentDescription = null, - ) - } - - DropdownMenu( - modifier = Modifier - .widthIn(min = DropdownMinWidth), - expanded = dropdownShownState.value, - onDismissRequest = onDismissRequest, - ) { - val scope = DropdownScopeImpl(this, onDismissRequest = onDismissRequest) - with(scope) { - field.actions.forEachIndexed { index, action -> - DropdownMenuItemFlat( - action = action, - ) - } - } - } - }, - trailing = { - OptionsButton( - actions = field.options, - ) - }, - ) -} - -@Composable -private fun SwitchField( - modifier: Modifier = Modifier, - item: AddStateItem.Switch, -) { - val localState by item.state.flow.collectAsState() - FlatItem( - modifier = modifier, - leading = { - Checkbox( - checked = localState.checked, - enabled = localState.onChange != null, - onCheckedChange = null, - ) - }, - title = { - Text( - text = item.title, - ) - }, - text = if (item.text != null) { - // composable - { - Text( - text = item.text, - ) - } - } else { - null - }, - onClick = localState.onChange?.partially1(!localState.checked), - ) -} - -@Composable -private fun DateMonthYearField( - modifier: Modifier = Modifier, - item: AddStateItem.DateMonthYear, -) { - val localState by item.state.flow.collectAsState() - BiFlatContainer( - modifier = modifier - .padding(horizontal = Dimens.horizontalPadding), - contentModifier = Modifier - .clickable( - indication = LocalIndication.current, - interactionSource = remember { - MutableInteractionSource() - }, - role = Role.Button, - ) { - localState.onClick.invoke() - }, - isError = rememberUpdatedState(newValue = false), - isFocused = rememberUpdatedState(newValue = false), - isEmpty = rememberUpdatedState(newValue = false), - label = { - Text( - text = item.label, - style = MaterialTheme.typography.bodySmall, - ) - }, - content = { - val density = LocalDensity.current - Row( - modifier = Modifier - .graphicsLayer { - translationY = 12f + density.density - }, - ) { - Row( - modifier = Modifier - .heightIn(min = BiFlatValueHeightMin) - .weight(1f, fill = false), - horizontalArrangement = Arrangement.spacedBy(2.dp), - ) { - val month = localState.month.text.ifBlank { "--" } - val year = localState.year.text.ifBlank { "----" } - Text( - text = month, - ) - Text( - text = "/", - color = LocalContentColor.current - .combineAlpha(DisabledEmphasisAlpha), - ) - Text( - text = year, - ) - } -// Spacer( -// modifier = Modifier -// .width(4.dp), -// ) -// Icon( -// modifier = Modifier -// .alpha(MediumEmphasisAlpha), -// imageVector = Icons.Outlined.ArrowDropDown, -// contentDescription = null, -// ) - } - }, - trailing = { - val isEmpty = localState.month.state.value.isEmpty() && - localState.year.state.value.isEmpty() - ExpandedIfNotEmptyForRow( - valueOrNull = Unit.takeUnless { isEmpty }, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Spacer( - modifier = Modifier - .width(8.dp), - ) - VerticalDivider( - modifier = Modifier - .height(24.dp), - ) - Spacer( - modifier = Modifier - .width(8.dp), - ) - IconButton( - enabled = true, - onClick = { - localState.month.state.value = "" - localState.year.state.value = "" - }, - ) { - Icon( - imageVector = Icons.Outlined.Clear, - contentDescription = null, - ) - } - } - } - }, - ) -} - -@Composable -private fun EnumItem( - modifier: Modifier = Modifier, - icon: ImageVector, - label: String, - title: String, - dropdown: List, -) { - FlatDropdown( - modifier = modifier, - content = { - FlatItemTextContent2( - title = { - Text(label) - }, - text = { - Text(title) - }, - ) - }, - leading = { - Icon(icon, null) - }, - dropdown = dropdown, - ) -} - -@Composable -private fun SectionItem( - modifier: Modifier = Modifier, - title: String?, -) { - Section( - modifier = modifier, - text = title, - ) -} - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) -@Composable -private fun SuggestionItem( - modifier: Modifier = Modifier, - item: AddStateItem.Suggestion, -) { - val localState by item.state.flow.collectAsState() - FlowRow( - modifier = modifier - .padding(horizontal = Dimens.horizontalPadding), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - localState.items.forEach { item -> - key(item.key) { - SuggestionItemChip( - modifier = Modifier, - item = item, - ) - } - } - } -} - -@Composable -private fun SuggestionItemChip( - modifier: Modifier = Modifier, - item: AddStateItem.Suggestion.Item, -) { - val backgroundColor = - if (item.selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface - Surface( - modifier = modifier, - border = if (item.selected) null else BorderStroke(0.dp, DividerColor), - color = backgroundColor, - shape = MaterialTheme.shapes.small, - ) { - val contentColor = LocalContentColor.current - .let { color -> - if (item.onClick != null) { - color // enabled - } else { - color.combineAlpha(DisabledEmphasisAlpha) - } - } - CompositionLocalProvider( - LocalContentColor provides contentColor, - ) { - Column( - modifier = Modifier - .then( - if (item.onClick != null) { - Modifier - .clickable(role = Role.Button) { - item.onClick.invoke() - } - } else { - Modifier - }, - ) - .padding( - horizontal = 12.dp, - vertical = 8.dp, - ), - ) { - Text( - text = item.text, - style = MaterialTheme.typography.bodyMedium, - ) - Text( - text = item.source, - style = MaterialTheme.typography.bodySmall, - color = LocalContentColor.current - .combineAlpha(MediumEmphasisAlpha), - ) - } - } - } -} - -// -// Ownership -// - -@Composable -private fun ToolbarContentItem( - element: AddState.SaveToElement, -) { - element.items.forEach { - key(it.key) { - Column( - modifier = Modifier - .heightIn(min = 32.dp), - verticalArrangement = Arrangement.Center, - ) { - Text( - text = it.title, - style = MaterialTheme.typography.bodyMedium, - ) - if (it.text != null) { - Text( - text = it.text, - color = LocalContentColor.current - .combineAlpha(MediumEmphasisAlpha), - style = MaterialTheme.typography.bodySmall, - ) - } - } - } - } -} - -@Composable -private fun ToolbarContentItemErr( - modifier: Modifier = Modifier, - icon: ImageVector, - element: AddState.SaveToElement, -) { - ToolbarContentItemErr( - modifier = modifier, - leading = { - Icon( - imageVector = icon, - contentDescription = null, - ) - }, - element = element, - ) -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun ToolbarContentItemErr( - modifier: Modifier = Modifier, - leading: @Composable () -> Unit, - element: AddState.SaveToElement, -) { - val alpha = if (!element.readOnly) DefaultEmphasisAlpha else DisabledEmphasisAlpha - Row( - modifier = modifier - .heightIn(min = 32.dp) - .alpha(alpha), - ) { - Box( - modifier = Modifier - .size(32.dp), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier - .size(16.dp), - ) { - leading() - } - } - Spacer(modifier = Modifier.width(14.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - ToolbarContentItem(element = element) - } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun ToolbarContentItemErrSkeleton( - modifier: Modifier = Modifier, - fraction: Float = 1f, -) { - Row( - modifier = modifier - .heightIn(min = 32.dp), - ) { - Box( - modifier = Modifier - .size(32.dp), - contentAlignment = Alignment.Center, - ) { - val contentColor = LocalContentColor.current - .combineAlpha(DisabledEmphasisAlpha) - Box( - modifier = Modifier - .shimmer() - .size(16.dp) - .clip(MaterialTheme.shapes.small) - .background(contentColor), - ) - } - Spacer(modifier = Modifier.width(14.dp)) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Column( - modifier = Modifier - .heightIn(min = 32.dp), - verticalArrangement = Arrangement.Center, - ) { - SkeletonText( - modifier = Modifier - .fillMaxWidth(fraction), - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } -} - -@Composable -private fun ToolbarContent( - modifier: Modifier = Modifier, - account: AddState.SaveToElement? = null, - organization: AddState.SaveToElement? = null, - collection: AddState.SaveToElement? = null, - folder: AddState.SaveToElement? = null, - onClick: (() -> Unit)? = null, -) { - Column( - modifier = modifier - .fillMaxWidth() - .padding( - horizontal = 8.dp, - vertical = 2.dp, - ) - .clip(MaterialTheme.shapes.medium) - .then( - if (onClick != null) { - Modifier - .clickable( - role = Role.Button, - onClick = onClick, - ) - } else { - Modifier - }, - ) - .padding(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(end = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (account != null) { - ToolbarContentItemErr( - modifier = Modifier - .weight(1.5f), - leading = { - val accentColors = account.items.firstOrNull() - ?.accentColors - ?: return@ToolbarContentItemErr - val backgroundColor = if (MaterialTheme.colorScheme.isDark) { - accentColors.dark - } else { - accentColors.light - } - Box( - modifier = Modifier - .size(24.dp) - .background(backgroundColor, CircleShape), - ) - }, - element = account, - ) - Spacer(modifier = Modifier.width(8.dp)) - } - if (folder != null) { - ToolbarContentItemErr( - modifier = Modifier - .weight(1f) - .padding(end = 8.dp), - icon = Icons.Outlined.Folder, - element = folder, - ) - } - } - ExpandedIfNotEmpty( - valueOrNull = organization, - ) { org -> - ToolbarContentItemErr( - modifier = Modifier - .padding(end = 8.dp), - icon = Icons.Outlined.KeyguardOrganization, - element = org, - ) - } - ExpandedIfNotEmpty( - valueOrNull = collection, - ) { col -> - ToolbarContentItemErr( - modifier = Modifier - .padding(end = 8.dp), - icon = Icons.Outlined.KeyguardCollection, - element = col, - ) - } - } } diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/AddState.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/AddState.kt index afb3d5c..3f66b1e 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/AddState.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/AddState.kt @@ -1,6 +1,5 @@ package com.artemchep.keyguard.feature.home.vault.add -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape @@ -13,6 +12,8 @@ import com.artemchep.keyguard.common.model.TotpToken import com.artemchep.keyguard.common.model.UsernameVariation2 import com.artemchep.keyguard.common.model.create.CreateRequest import com.artemchep.keyguard.common.usecase.CopyText +import com.artemchep.keyguard.feature.add.AddStateItem +import com.artemchep.keyguard.feature.add.AddStateOwnership import com.artemchep.keyguard.feature.auth.common.SwitchFieldModel import com.artemchep.keyguard.feature.auth.common.TextFieldModel2 import com.artemchep.keyguard.feature.confirmation.organization.FolderInfo @@ -46,11 +47,7 @@ data class AddState( data class Ownership( val data: Data, - val account: SaveToElement? = null, - val organization: SaveToElement? = null, - val collection: SaveToElement? = null, - val folder: SaveToElement? = null, - val onClick: (() -> Unit)? = null, + val ui: AddStateOwnership, ) { data class Data( val accountId: String?, @@ -65,281 +62,4 @@ data class AddState( val note: SimpleNote?, val removeOrigin: SwitchFieldModel, ) - - data class SaveToElement( - val readOnly: Boolean, - val items: List = emptyList(), - ) { - data class Item( - val key: String, - val title: String, - val text: String? = null, - val accentColors: AccentColors? = null, - ) - } } - -sealed interface AddStateItem { - val id: String - - interface HasOptions { - val options: List - - /** - * Copies the data class replacing the old options with a - * provided ones. - */ - fun withOptions( - options: List, - ): T - } - - interface HasDecor { - val decor: Decor - } - - interface HasState { - val state: LocalStateItem - } - - data class Decor( - val shape: Shape = RectangleShape, - val elevation: Dp = 0.dp, - ) - - data class Title( - override val id: String, - override val state: LocalStateItem, - ) : AddStateItem, HasState - - data class Username( - override val id: String, - override val state: LocalStateItem, - ) : AddStateItem, HasState { - data class State( - val value: TextFieldModel2, - val type: UsernameVariation2, - ) - } - - data class Password( - override val id: String, - override val state: LocalStateItem, - ) : AddStateItem, HasState - - data class Text( - override val id: String, - override val decor: Decor = Decor(), - override val state: LocalStateItem, - ) : AddStateItem, HasDecor, HasState { - data class State( - val value: TextFieldModel2, - val label: String? = null, - val singleLine: Boolean = false, - val keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - val visualTransformation: VisualTransformation = VisualTransformation.None, - ) - } - - data class Suggestion( - override val id: String, - override val decor: Decor = Decor(), - override val state: LocalStateItem, - ) : AddStateItem, HasDecor, HasState { - data class State( - val items: ImmutableList, - ) - - data class Item( - val key: String, - val text: String, - val value: String, - val source: String, - val selected: Boolean, - val onClick: (() -> Unit)? = null, - ) - } - - data class Totp( - override val id: String, - override val state: LocalStateItem, - ) : AddStateItem, HasState { - data class State( - val copyText: CopyText, - val value: TextFieldModel2, - val totpToken: TotpToken? = null, - ) - } - - data class Passkey( - override val id: String, - override val options: List = emptyList(), - override val state: LocalStateItem, - ) : AddStateItem, HasOptions, HasState { - override fun withOptions( - options: List, - ): Passkey = copy( - options = options, - ) - - data class State( - val passkey: DSecret.Login.Fido2Credentials?, - ) - } - - data class Attachment( - override val id: String, - override val options: List = emptyList(), - override val state: LocalStateItem, - ) : AddStateItem, HasOptions, HasState { - override fun withOptions(options: List): Attachment = - copy( - options = options, - ) - - data class State( - val id: String, - val name: TextFieldModel2, - val size: String? = null, - /** - * `true` if the attachment is already uploaded to the server, - * `false` otherwise. - */ - val synced: Boolean, - ) - } - - data class Url( - override val id: String, - override val options: List = emptyList(), - override val state: LocalStateItem, - ) : AddStateItem, HasOptions, HasState { - override fun withOptions(options: List): Url = - copy( - options = options, - ) - - data class State( - override val options: List = emptyList(), - val text: TextFieldModel2, - val matchType: DSecret.Uri.MatchType? = null, - val matchTypeTitle: String? = null, - ) : HasOptions { - override fun withOptions(options: List): State = - copy( - options = options, - ) - } - } - - data class Field( - override val id: String, - override val options: List = emptyList(), - override val state: LocalStateItem, - ) : AddStateItem, HasOptions, HasState { - override fun withOptions(options: List): Field = - copy( - options = options, - ) - - sealed interface State : HasOptions { - data class Text( - override val options: List = emptyList(), - val label: TextFieldModel2, - val text: TextFieldModel2, - val hidden: Boolean = false, - ) : State { - override fun withOptions(options: List): Text = - copy( - options = options, - ) - } - - data class Switch( - override val options: List = emptyList(), - val checked: Boolean = false, - val onCheckedChange: ((Boolean) -> Unit)? = null, - val label: TextFieldModel2, - ) : State { - override fun withOptions(options: List): Switch = - copy( - options = options, - ) - } - - data class LinkedId( - override val options: List = emptyList(), - val value: DSecret.Field.LinkedId?, - val actions: List, - val label: TextFieldModel2, - ) : State { - override fun withOptions(options: List): LinkedId = - copy( - options = options, - ) - } - } - } - - data class Note( - override val id: String, - override val state: LocalStateItem, - val markdown: Boolean, - ) : AddStateItem, HasState - - data class Enum( - override val id: String, - val icon: ImageVector, - val label: String, - override val state: LocalStateItem, - ) : AddStateItem, HasState { - data class State( - val value: String = "", - val dropdown: List = emptyList(), - ) - } - - data class Switch( - override val id: String, - val title: String, - val text: String? = null, - override val state: LocalStateItem, - ) : AddStateItem, HasState - - data class Section( - override val id: String, - val text: String? = null, - ) : AddStateItem - - // - // CUSTOM - // - - data class DateMonthYear( - override val id: String, - override val state: LocalStateItem, - val label: String, - ) : AddStateItem, HasState { - data class State( - val month: TextFieldModel2, - val year: TextFieldModel2, - val onClick: () -> Unit, - ) - } - - // - // Items that may modify the amount of items in the - // list. - // - - data class Add( - override val id: String, - val text: String, - val actions: List, - ) : AddStateItem -} - -data class LocalStateItem( - val flow: StateFlow, - val populator: CreateRequest.(T) -> CreateRequest = { this }, -) diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/AddStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/AddStateProducer.kt index 37cf42a..c4c71f2 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/AddStateProducer.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/AddStateProducer.kt @@ -95,6 +95,9 @@ import com.artemchep.keyguard.common.util.flow.combineToList import com.artemchep.keyguard.common.util.flow.foldAsList import com.artemchep.keyguard.common.util.flow.persistingStateIn import com.artemchep.keyguard.common.util.validLuhn +import com.artemchep.keyguard.feature.add.AddStateItem +import com.artemchep.keyguard.feature.add.AddStateOwnership +import com.artemchep.keyguard.feature.add.LocalStateItem import com.artemchep.keyguard.feature.apppicker.AppPickerResult import com.artemchep.keyguard.feature.apppicker.AppPickerRoute import com.artemchep.keyguard.feature.auth.common.SwitchFieldModel @@ -130,6 +133,7 @@ import com.artemchep.keyguard.provider.bitwarden.usecase.autofill import com.artemchep.keyguard.res.Res import com.artemchep.keyguard.ui.FlatItemAction import com.artemchep.keyguard.ui.SimpleNote +import com.artemchep.keyguard.ui.buildContextItems import com.artemchep.keyguard.ui.icons.ChevronIcon import com.artemchep.keyguard.ui.icons.IconBox import com.artemchep.keyguard.ui.icons.Stub @@ -458,7 +462,7 @@ fun produceAddScreenState( val item = AddStateItem.Add( id = "attachment.add", text = translate(Res.strings.list_add), - actions = listOf(action), + actions = persistentListOf(action), ) flowOf(emptyList()) }, @@ -522,7 +526,7 @@ fun produceAddScreenState( id = "reprompt", title = translate(Res.strings.additem_auth_reprompt_title), text = translate(Res.strings.additem_auth_reprompt_text), - state = LocalStateItem( + state = LocalStateItem( flow = kotlin.run { val sink = mutablePersistedFlow("reprompt") { args.initialValue?.reprompt @@ -650,7 +654,7 @@ fun produceAddScreenState( }, ) - val titleItem = AddStateItem.Title( + val titleItem = AddStateItem.Title( id = "title", state = LocalStateItem( flow = kotlin.run { @@ -707,7 +711,7 @@ fun produceAddScreenState( .map { items -> items .mapNotNull { item -> - val stateHolder = item as? AddStateItem.HasState + val stateHolder = item as? AddStateItem.HasState ?: return@mapNotNull null val state = stateHolder.state @@ -858,7 +862,7 @@ fun produceAddScreenState( f } -class AddStateItemAttachmentFactory : Foo2Factory { +class AddStateItemAttachmentFactory : Foo2Factory, DSecret.Attachment> { override val type: String = "attachment" override fun RememberStateFlowScope.release(key: String) { @@ -868,7 +872,7 @@ class AddStateItemAttachmentFactory : Foo2Factory { val nameKey = "$key.name" val nameSink = mutablePersistedFlow(nameKey) { initial?.fileName().orEmpty() @@ -902,7 +906,7 @@ class AddStateItemAttachmentFactory : Foo2Factory( id = key, state = LocalStateItem( flow = stateFlow, @@ -916,7 +920,7 @@ class AddStateItemAttachmentFactory : Foo2Factory { +) : Foo2Factory, DSecret.Uri> { override val type: String = "uri" override fun RememberStateFlowScope.release(key: String) { @@ -927,7 +931,7 @@ class AddStateItemUriFactory( override fun RememberStateFlowScope.add( key: String, initial: DSecret.Uri?, - ): AddStateItem.Url { + ): AddStateItem.Url { val uriKey = "$key.uri" val uriSink = mutablePersistedFlow(uriKey) { initial?.uri.orEmpty() @@ -1018,7 +1022,10 @@ class AddStateItemUriFactory( } val actionsFlow = actionsMatchTypeItemFlow .map { - listOf(it, actionsAppPickerItem) + buildContextItems { + this += it + this += actionsAppPickerItem + } } val textFlow = uriSink @@ -1090,7 +1097,7 @@ class AddStateItemUriFactory( matchType = DSecret.Uri.MatchType.default, ), ) - return AddStateItem.Url( + return AddStateItem.Url( id = key, state = LocalStateItem( flow = stateFlow, @@ -1108,7 +1115,7 @@ class AddStateItemUriFactory( } class AddStateItemPasskeyFactory( -) : Foo2Factory { +) : Foo2Factory, DSecret.Login.Fido2Credentials> { @kotlinx.serialization.Serializable private data class PasskeyHolder( val data: PasskeyData? = null, @@ -1158,7 +1165,7 @@ class AddStateItemPasskeyFactory( override fun RememberStateFlowScope.add( key: String, initial: DSecret.Login.Fido2Credentials?, - ): AddStateItem.Passkey { + ): AddStateItem.Passkey { val dataKey = "$key.data" val dataSink = mutablePersistedFlow( dataKey, @@ -1203,7 +1210,7 @@ class AddStateItemPasskeyFactory( passkey = null, ), ) - return AddStateItem.Passkey( + return AddStateItem.Passkey( id = key, state = LocalStateItem( flow = stateFlow, @@ -1220,11 +1227,11 @@ class AddStateItemPasskeyFactory( } } -abstract class AddStateItemFieldFactory : Foo2Factory { +abstract class AddStateItemFieldFactory : Foo2Factory, DSecret.Field> { fun foo( key: String, flow: StateFlow, - ) = AddStateItem.Field( + ) = AddStateItem.Field( id = key, state = LocalStateItem( flow = flow, @@ -1282,7 +1289,7 @@ class AddStateItemFieldTextFactory : AddStateItemFieldFactory() { override fun RememberStateFlowScope.add( key: String, initial: DSecret.Field?, - ): AddStateItem.Field { + ): AddStateItem.Field { val labelSink = mutablePersistedFlow("$key.label") { initial?.name.orEmpty() } @@ -1342,9 +1349,9 @@ class AddStateItemFieldTextFactory : AddStateItemFieldFactory() { } val actionsFlow = actionsConcealItemFlow .map { concealItem -> - listOf( - concealItem, - ) + buildContextItems { + this += concealItem + } } val stateFlow = combine( @@ -1386,7 +1393,7 @@ class AddStateItemFieldBooleanFactory : AddStateItemFieldFactory() { override fun RememberStateFlowScope.add( key: String, initial: DSecret.Field?, - ): AddStateItem.Field { + ): AddStateItem.Field { val labelSink = mutablePersistedFlow("$key.label") { initial?.name.orEmpty() } @@ -1441,7 +1448,7 @@ class AddStateItemFieldLinkedIdFactory( override fun RememberStateFlowScope.add( key: String, initial: DSecret.Field?, - ): AddStateItem.Field { + ): AddStateItem.Field { val labelSink = mutablePersistedFlow("$key.label") { initial?.name.orEmpty() } @@ -1488,11 +1495,13 @@ class AddStateItemFieldLinkedIdFactory( } val actionsFlow = typeFlow .map { type -> - actionsAll - .mapNotNull { (itemLinkedId, itemAction) -> - itemAction - .takeIf { itemLinkedId.type == type } - } + buildContextItems { + actionsAll + .forEach { (itemLinkedId, itemAction) -> + this += itemAction + .takeIf { itemLinkedId.type == type } + } + } } val stateFlow = combine( labelFlow, @@ -1511,7 +1520,7 @@ class AddStateItemFieldLinkedIdFactory( initialValue = AddStateItem.Field.State.LinkedId( label = TextFieldModel2.empty, value = null, - actions = emptyList(), + actions = persistentListOf(), ), ) return foo( @@ -1646,15 +1655,16 @@ private fun FieldBakeryScope.typeBasedAddItem( return@map null // hide add item } - val actions = types - .map { type -> - FlatItemAction( + val actions = buildContextItems { + types.forEach { type -> + this += FlatItemAction( title = type.name, onClick = ::add .partially1(type.type) .partially1(null), ) } + } AddStateItem.Add( id = "$scope.add", text = translator.translate(Res.strings.list_add), @@ -1795,35 +1805,35 @@ fun RememberStateFlowScope.foo( // Appends list-specific options to be able to reorder // the list or remove an item. - val options = item.options.toMutableList() - if (index > 0) { - options += FlatItemAction( - icon = Icons.Outlined.ArrowUpward, - title = translate(Res.strings.list_move_up), - onClick = ::moveUp.partially1(entry.key), + val options = buildContextItems(item.options) { + if (index > 0) { + this += FlatItemAction( + icon = Icons.Outlined.ArrowUpward, + title = translate(Res.strings.list_move_up), + onClick = ::moveUp.partially1(entry.key), + ) + } + if (index < state.size - 1) { + this += FlatItemAction( + icon = Icons.Outlined.ArrowDownward, + title = translate(Res.strings.list_move_down), + onClick = ::moveDown.partially1(entry.key), + ) + } + this += FlatItemAction( + icon = Icons.Outlined.DeleteForever, + title = translate(Res.strings.list_remove), + onClick = { + val intent = createConfirmationDialogIntent( + icon = icon(Icons.Outlined.DeleteForever), + title = translate(Res.strings.list_remove_confirmation_title), + ) { + delete(entry.key) + } + navigate(intent) + }, ) } - if (index < state.size - 1) { - options += FlatItemAction( - icon = Icons.Outlined.ArrowDownward, - title = translate(Res.strings.list_move_down), - onClick = ::moveDown.partially1(entry.key), - ) - } - options += FlatItemAction( - icon = Icons.Outlined.DeleteForever, - title = translate(Res.strings.list_remove), - onClick = { - val intent = createConfirmationDialogIntent( - icon = icon(Icons.Outlined.DeleteForever), - title = translate(Res.strings.list_remove_confirmation_title), - ) { - delete(entry.key) - } - navigate(intent) - }, - ) - item.withOptions(options) } } @@ -1853,7 +1863,7 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow( data class Fool( val value: T, - val element: AddState.SaveToElement?, + val element: AddStateOwnership.Element?, ) val disk = loadDiskHandle("new_item") @@ -1882,31 +1892,31 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow( // Make an account that has the most ciphers a // default account. val accountId = ioEffect { - val accountIds = getProfiles().toIO().bind() - .map { it.accountId() } - .toSet() + val accountIds = getProfiles().toIO().bind() + .map { it.accountId() } + .toSet() - fun String.takeIfAccountIdExists() = this - .takeIf { id -> - id in accountIds - } - - val lastAccountId = accountIdSink.value?.takeIfAccountIdExists() - if (lastAccountId != null) { - return@ioEffect lastAccountId + fun String.takeIfAccountIdExists() = this + .takeIf { id -> + id in accountIds } - val ciphers = getCiphers().toIO().bind() - ciphers - .asSequence() - .filter { it.organizationId != null } - .groupBy { it.accountId } - // the one that has the most ciphers - .maxByOrNull { entry -> entry.value.size } - // account id - ?.key - ?.takeIfAccountIdExists() - }.attempt().bind().getOrNull() + val lastAccountId = accountIdSink.value?.takeIfAccountIdExists() + if (lastAccountId != null) { + return@ioEffect lastAccountId + } + + val ciphers = getCiphers().toIO().bind() + ciphers + .asSequence() + .filter { it.organizationId != null } + .groupBy { it.accountId } + // the one that has the most ciphers + .maxByOrNull { entry -> entry.value.size } + // account id + ?.key + ?.takeIfAccountIdExists() + }.attempt().bind().getOrNull() AddItemOwnershipData( accountId = accountId, @@ -1934,11 +1944,12 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow( getProfiles(), ) { accountId, profiles -> if (accountId == null) { - val item = AddState.SaveToElement.Item( + val item = AddStateOwnership.Element.Item( key = "account.empty", title = translate(Res.strings.account_none), + stub = true, ) - val el = AddState.SaveToElement( + val el = AddStateOwnership.Element( readOnly = ro, items = listOf(item), ) @@ -1949,12 +1960,12 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow( } val profileOrNull = profiles .firstOrNull { it.accountId() == accountId } - val el = AddState.SaveToElement( + val el = AddStateOwnership.Element( readOnly = ro, items = listOfNotNull(profileOrNull) .map { account -> val key = "account.${account.accountId()}" - AddState.SaveToElement.Item( + AddStateOwnership.Element.Item( key = key, title = account.email, text = account.accountHost, @@ -1975,11 +1986,12 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow( getOrganizations(), ) { organizationId, organizations -> if (organizationId == null) { - val item = AddState.SaveToElement.Item( + val item = AddStateOwnership.Element.Item( key = "organization.empty", title = translate(Res.strings.organization_none), + stub = true, ) - val el = AddState.SaveToElement( + val el = AddStateOwnership.Element( readOnly = ro, items = listOf(item), ) @@ -1990,12 +2002,12 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow( } val organizationOrNull = organizations .firstOrNull { it.id == organizationId } - val el = AddState.SaveToElement( + val el = AddStateOwnership.Element( readOnly = ro, items = listOfNotNull(organizationOrNull) .map { organization -> val key = "organization.${organization.id}" - AddState.SaveToElement.Item( + AddStateOwnership.Element.Item( key = key, title = organization.name, ) @@ -2023,12 +2035,12 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow( val selectedCollections = collections .sortedWith(StringComparatorIgnoreCase { it.name }) .filter { it.id in collectionIds } - val el = AddState.SaveToElement( + val el = AddStateOwnership.Element( readOnly = ro, items = selectedCollections .map { collection -> val key = "collection.${collection.id}" - AddState.SaveToElement.Item( + AddStateOwnership.Element.Item( key = key, title = collection.name, ) @@ -2048,11 +2060,12 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow( ) { selectedFolder, folders -> when (selectedFolder) { is FolderInfo.None -> { - val item = AddState.SaveToElement.Item( + val item = AddStateOwnership.Element.Item( key = "folder.empty", title = translate(Res.strings.folder_none), + stub = true, ) - val el = AddState.SaveToElement( + val el = AddStateOwnership.Element( readOnly = false, items = listOf(item), ) @@ -2063,11 +2076,12 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow( } is FolderInfo.New -> { - val item = AddState.SaveToElement.Item( + val item = AddStateOwnership.Element.Item( key = "folder.new", title = selectedFolder.name, + stub = true, ) - val el = AddState.SaveToElement( + val el = AddStateOwnership.Element( readOnly = false, items = listOf(item), ) @@ -2080,12 +2094,12 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow( is FolderInfo.Id -> { val selectedFolderOrNull = folders .firstOrNull { it.id == selectedFolder.id } - val el = AddState.SaveToElement( + val el = AddStateOwnership.Element( readOnly = false, items = listOfNotNull(selectedFolderOrNull) .map { folder -> val key = "folder.${folder.id}" - AddState.SaveToElement.Item( + AddStateOwnership.Element.Item( key = key, title = folder.name, ) @@ -2118,8 +2132,7 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow( organizationId = organization.value, collectionIds = collection.value, ) - AddState.Ownership( - data = data, + val ui = AddStateOwnership( account = account.element, organization = organization.element.takeIf { account.value != null }, collection = collection.element.takeIf { account.value != null }, @@ -2129,7 +2142,7 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow( route = OrganizationConfirmationRoute( args = OrganizationConfirmationRoute.Args( decor = OrganizationConfirmationRoute.Args.Decor( - title = "Save to", + title = translate(Res.strings.save_to), icon = Icons.Outlined.AccountBox, ), flags = flags, @@ -2154,50 +2167,54 @@ private suspend fun RememberStateFlowScope.produceOwnershipFlow( navigate(intent) }, ) + AddState.Ownership( + data = data, + ui = ui, + ) } } data class TmpLogin( - val username: AddStateItem.Username, - val password: AddStateItem.Password, - val totp: AddStateItem.Totp, + val username: AddStateItem.Username, + val password: AddStateItem.Password, + val totp: AddStateItem.Totp, val items: List, ) data class TmpCard( - val cardholderName: AddStateItem.Text, - val brand: AddStateItem.Text, - val number: AddStateItem.Text, - val fromDate: AddStateItem.DateMonthYear, - val expDate: AddStateItem.DateMonthYear, - val code: AddStateItem.Text, + val cardholderName: AddStateItem.Text, + val brand: AddStateItem.Text, + val number: AddStateItem.Text, + val fromDate: AddStateItem.DateMonthYear, + val expDate: AddStateItem.DateMonthYear, + val code: AddStateItem.Text, val items: List, ) data class TmpIdentity( - val title: AddStateItem.Text, - val firstName: AddStateItem.Text, - val middleName: AddStateItem.Text, - val lastName: AddStateItem.Text, - val address1: AddStateItem.Text, - val address2: AddStateItem.Text, - val address3: AddStateItem.Text, - val city: AddStateItem.Text, - val state: AddStateItem.Text, - val postalCode: AddStateItem.Text, - val country: AddStateItem.Text, - val company: AddStateItem.Text, - val email: AddStateItem.Text, - val phone: AddStateItem.Text, - val ssn: AddStateItem.Text, - val username: AddStateItem.Text, - val passportNumber: AddStateItem.Text, - val licenseNumber: AddStateItem.Text, + val title: AddStateItem.Text, + val firstName: AddStateItem.Text, + val middleName: AddStateItem.Text, + val lastName: AddStateItem.Text, + val address1: AddStateItem.Text, + val address2: AddStateItem.Text, + val address3: AddStateItem.Text, + val city: AddStateItem.Text, + val state: AddStateItem.Text, + val postalCode: AddStateItem.Text, + val country: AddStateItem.Text, + val company: AddStateItem.Text, + val email: AddStateItem.Text, + val phone: AddStateItem.Text, + val ssn: AddStateItem.Text, + val username: AddStateItem.Text, + val passportNumber: AddStateItem.Text, + val licenseNumber: AddStateItem.Text, val items: List, ) data class TmpNote( - val note: AddStateItem.Note, + val note: AddStateItem.Note, val items: List, ) @@ -2214,7 +2231,7 @@ private suspend fun RememberStateFlowScope.produceLoginState( key: String, initialValue: String? = null, populator: CreateRequest.(AddStateItem.Username.State) -> CreateRequest, - factory: (String, LocalStateItem) -> Item, + factory: (String, LocalStateItem) -> Item, ) = kotlin.run { val id = "$prefix.$key" @@ -2225,9 +2242,10 @@ private suspend fun RememberStateFlowScope.produceLoginState( LocalStateItem( flow = ownershipFlow .map { - it.account + it.ui.account ?.items ?.asSequence() + ?.filter { !it.stub } ?.map { it.title } ?.toPersistentList() ?: persistentListOf() @@ -2262,7 +2280,7 @@ private suspend fun RememberStateFlowScope.produceLoginState( key: String, initialValue: String? = null, populator: CreateRequest.(TextFieldModel2) -> CreateRequest, - factory: (String, LocalStateItem) -> Item, + factory: (String, LocalStateItem) -> Item, ) = kotlin.run { val id = "$prefix.$key" @@ -2381,7 +2399,7 @@ private suspend fun RememberStateFlowScope.produceLoginState( totpToken = totp, ) } - LocalStateItem( + LocalStateItem( flow = flow .persistingStateIn( scope = screenScope, @@ -2444,7 +2462,7 @@ private suspend fun RememberStateFlowScope.produceCardState( initialMonth: String? = null, initialYear: String? = null, populator: CreateRequest.(AddStateItem.DateMonthYear.State) -> CreateRequest, - factory: (String, LocalStateItem) -> Item, + factory: (String, LocalStateItem) -> Item, ): Item { val id = "$prefix.$key" @@ -2557,7 +2575,7 @@ private suspend fun RememberStateFlowScope.produceCardState( keyboardOptions: KeyboardOptions = KeyboardOptions.Default, visualTransformation: VisualTransformation = VisualTransformation.None, lens: Optional, - ) = createItem( + ) = createItem( prefix = prefix, key = key, label = label, @@ -2587,7 +2605,7 @@ private suspend fun RememberStateFlowScope.produceCardState( val sink = mutablePersistedFlow(id) { initialValue.orEmpty() } val state = mutableComposeState(sink) - val state2 = LocalStateItem( + val state2 = LocalStateItem( flow = sink .map { value -> val isValid = kotlin.run { @@ -2899,7 +2917,7 @@ private suspend fun RememberStateFlowScope.produceIdentityState( autocompleteOptions: ImmutableList = persistentListOf(), keyboardOptions: KeyboardOptions = KeyboardOptions.Default, lens: Optional, - ) = createItem( + ) = createItem( prefix = prefix, key = key, label = label, @@ -3255,7 +3273,7 @@ private suspend fun RememberStateFlowScope.produceNoteState( val sink = mutablePersistedFlow(id) { initialValue.orEmpty() } val state = mutableComposeState(sink) - val stateItem = LocalStateItem( + val stateItem = LocalStateItem( flow = sink .map { value -> val model = TextFieldModel2( @@ -3300,7 +3318,7 @@ private suspend fun RememberStateFlowScope.produceNoteState( ) } -private suspend fun RememberStateFlowScope.createItem( +suspend fun RememberStateFlowScope.createItem( prefix: String, key: String, label: String? = null, @@ -3310,7 +3328,7 @@ private suspend fun RememberStateFlowScope.createItem( singleLine: Boolean = false, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, visualTransformation: VisualTransformation = VisualTransformation.None, - populator: CreateRequest.(AddStateItem.Text.State) -> CreateRequest, + populator: Request.(AddStateItem.Text.State) -> Request, ) = createItem( prefix = prefix, key = key, @@ -3329,7 +3347,7 @@ private suspend fun RememberStateFlowScope.createItem( ) } -private suspend fun RememberStateFlowScope.createItem( +suspend fun RememberStateFlowScope.createItem( prefix: String, key: String, label: String? = null, @@ -3339,8 +3357,8 @@ private suspend fun RememberStateFlowScope.createItem( singleLine: Boolean = false, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, visualTransformation: VisualTransformation = VisualTransformation.None, - populator: CreateRequest.(AddStateItem.Text.State) -> CreateRequest, - factory: (String, LocalStateItem) -> Item, + populator: Request.(AddStateItem.Text.State) -> Request, + factory: (String, LocalStateItem) -> Item, ): Item { val id = "$prefix.$key" @@ -3384,7 +3402,7 @@ private suspend fun RememberStateFlowScope.createItem2Txt( key: String, args: AddRoute.Args, getSuggestion: (DSecret) -> String?, - field: AddStateItem.Text, + field: AddStateItem.Text, concealed: Boolean = false, ) = createItem2( prefix = prefix, @@ -3406,7 +3424,7 @@ private suspend fun RememberStateFlowScope.createItem2( selectedFlow: Flow, concealed: Boolean = false, onClick: (String) -> Unit, -): AddStateItem.Suggestion? { +): AddStateItem.Suggestion? { val finalKey = "$prefix.$key.suggestions" val suggestions = args.merge ?.ciphers diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/attachment/SkeletonAttachmentItemFactory.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/attachment/SkeletonAttachmentItemFactory.kt index 6bfcc4a..fec1b98 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/attachment/SkeletonAttachmentItemFactory.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/attachment/SkeletonAttachmentItemFactory.kt @@ -1,17 +1,18 @@ package com.artemchep.keyguard.feature.home.vault.add.attachment import com.artemchep.keyguard.common.model.create.CreateRequest +import com.artemchep.keyguard.common.model.create.CreateSendRequest import com.artemchep.keyguard.common.model.create.attachments import com.artemchep.keyguard.common.util.flow.persistingStateIn +import com.artemchep.keyguard.feature.add.AddStateItem +import com.artemchep.keyguard.feature.add.LocalStateItem import com.artemchep.keyguard.feature.auth.common.TextFieldModel2 -import com.artemchep.keyguard.feature.home.vault.add.AddStateItem import com.artemchep.keyguard.feature.home.vault.add.Foo2Factory -import com.artemchep.keyguard.feature.home.vault.add.LocalStateItem import com.artemchep.keyguard.feature.navigation.state.RememberStateFlowScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map -class SkeletonAttachmentItemFactory : Foo2Factory { +class SkeletonAttachmentItemFactory : Foo2Factory, SkeletonAttachment> { override val type: String = "attachment" override fun RememberStateFlowScope.release(key: String) { @@ -22,7 +23,7 @@ class SkeletonAttachmentItemFactory : Foo2Factory { val identitySink = mutablePersistedFlow("$key.identity") { val identity = initial?.identity requireNotNull(identity) diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/util/changePasswordAction.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/util/changePasswordAction.kt index 9f6f0b0..5ed33fb 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/util/changePasswordAction.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/util/changePasswordAction.kt @@ -681,7 +681,7 @@ fun RememberStateFlowScope.cipherDeleteAction( route = ConfirmationRoute( args = ConfirmationRoute.Args( icon = icon(Icons.Outlined.DeleteForever), - title = "Delete forever?", + title = translate(Res.strings.ciphers_action_delete_confirmation_title), ), ), ) { result -> diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/SendListItemMapping.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/SendListItemMapping.kt index 4d69c21..2f0c4a6 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/SendListItemMapping.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/SendListItemMapping.kt @@ -44,7 +44,7 @@ suspend fun DSend.toVaultListItem( groupId = groupId, revisionDate = revisionDate, createdDate = createdDate, - hasPassword = password != null, + hasPassword = hasPassword, hasFile = type == DSend.Type.File, icon = icon, type = type.name, 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 6c22c2c..b1a9925 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 @@ -7,8 +7,11 @@ import com.artemchep.keyguard.common.model.DSend import com.artemchep.keyguard.feature.auth.common.TextFieldModel2 import com.artemchep.keyguard.feature.send.search.SendSortItem import com.artemchep.keyguard.feature.send.search.filter.SendFilterItem +import com.artemchep.keyguard.ui.ContextItem import com.artemchep.keyguard.ui.FlatItemAction import com.artemchep.keyguard.ui.Selection +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow @@ -23,7 +26,7 @@ data class SendListState( val clearFilters: (() -> Unit)? = null, val clearSort: (() -> Unit)? = null, val showKeyboard: Boolean = false, - val primaryActions: List = emptyList(), + val primaryActions: ImmutableList = persistentListOf(), val actions: List = emptyList(), val content: Content = Content.Skeleton, val sideEffects: SideEffects = SideEffects(), 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 c589200..5eae849 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 @@ -34,6 +34,7 @@ import com.artemchep.keyguard.common.usecase.GetSends import com.artemchep.keyguard.common.usecase.GetSuggestions import com.artemchep.keyguard.common.usecase.GetWebsiteIcons import com.artemchep.keyguard.common.usecase.QueueSyncAll +import com.artemchep.keyguard.common.usecase.SendToolbox import com.artemchep.keyguard.common.usecase.SupervisorRead import com.artemchep.keyguard.common.usecase.filterHiddenProfiles import com.artemchep.keyguard.common.util.flow.EventFlow @@ -59,6 +60,7 @@ import com.artemchep.keyguard.feature.navigation.state.PersistedStorage import com.artemchep.keyguard.feature.navigation.state.copy import com.artemchep.keyguard.feature.navigation.state.produceScreenState import com.artemchep.keyguard.feature.search.search.SEARCH_DEBOUNCE +import com.artemchep.keyguard.feature.send.add.SendAddRoute import com.artemchep.keyguard.feature.send.search.AccessCountSendSort import com.artemchep.keyguard.feature.send.search.AlphabeticalSendSort import com.artemchep.keyguard.feature.send.search.LastDeletedSendSort @@ -70,23 +72,28 @@ import com.artemchep.keyguard.feature.send.search.SendSortItem import com.artemchep.keyguard.feature.send.search.ah import com.artemchep.keyguard.feature.send.search.createFilter import com.artemchep.keyguard.feature.send.search.filter.FilterSendHolder +import com.artemchep.keyguard.feature.send.util.SendUtil import com.artemchep.keyguard.leof import com.artemchep.keyguard.platform.parcelize.LeParcelable import com.artemchep.keyguard.platform.parcelize.LeParcelize +import com.artemchep.keyguard.platform.util.isRelease 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.KeyguardView import com.artemchep.keyguard.ui.icons.SyncIcon import com.artemchep.keyguard.ui.icons.icon import com.artemchep.keyguard.ui.selection.selectionHandle import dev.icerock.moko.resources.StringResource +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn @@ -177,7 +184,7 @@ fun sendListScreenState( getProfiles: GetProfiles, getAppIcons: GetAppIcons, getWebsiteIcons: GetWebsiteIcons, - toolbox: CipherToolbox, + toolbox: SendToolbox, queueSyncAll: QueueSyncAll, syncSupervisor: SupervisorRead, dateFormatter: DateFormatter, @@ -227,6 +234,11 @@ fun sendListScreenState( } .launchIn(this) + val canEditFlow = SendUtil.canEditFlow( + profilesFlow = getProfiles(), + canWriteFlow = getCanWrite(), + ) + val showKeyboardSink = if (args.canAlwaysShowKeyboard) { mutablePersistedFlow( key = "keyboard", @@ -677,7 +689,50 @@ fun sendListScreenState( ) .stateIn(this, SharingStarted.WhileSubscribed(), OurFilterResult()) - val selectionFlow = flowOf(null) + fun createTypeAction( + type: DSend.Type, + ) = FlatItemAction( + leading = icon(type.iconImageVector()), + title = translate(type.titleH()), + onClick = { + val route = SendAddRoute( + args = SendAddRoute.Args( + type = type, + ), + ) + val intent = NavigationIntent.NavigateToRoute(route) + navigate(intent) + }, + ) + + val primaryActionsAll = buildContextItems { + this += createTypeAction( + type = DSend.Type.Text, + ) + this += createTypeAction( + type = DSend.Type.File, + ) + } + val primaryActionsFlow = kotlin.run { + combine( + canEditFlow, + selectionHandle.idsFlow, + ) { canEdit, selectedItemIds -> + if (canEdit && selectedItemIds.isEmpty() && !isRelease) { + primaryActionsAll + } else { + // No items + persistentListOf() + } + } + } + + val selectionFlow = SendUtil.selectionFlow( + selectionHandle = selectionHandle, + sendsFlow = ciphersRawFlow, + canEditFlow = canEditFlow, + toolbox = toolbox, + ) val itemsFlow = ciphersFilteredFlow .combine(selectionFlow) { ciphers, selection -> @@ -792,49 +847,6 @@ fun sendListScreenState( ) } - fun createTypeAction( - type: DSecret.Type, - ) = FlatItemAction( - leading = icon(type.iconImageVector()), - title = translate(type.titleH()), - onClick = { - val autofill = when (mode) { - is AppMode.Main -> null - is AppMode.PickPasskey -> null - is AppMode.SavePasskey -> null - is AppMode.Save -> { - AddRoute.Args.Autofill.leof(mode.args) - } - - is AppMode.Pick -> { - AddRoute.Args.Autofill.leof(mode.args) - } - } - val route = LeAddRoute( - args = AddRoute.Args( - type = type, - autofill = autofill, - ), - ) - val intent = NavigationIntent.NavigateToRoute(route) - navigate(intent) - }, - ) - - val primaryActions = listOf( - createTypeAction( - type = DSecret.Type.Login, - ), - createTypeAction( - type = DSecret.Type.Card, - ), - createTypeAction( - type = DSecret.Type.Identity, - ), - createTypeAction( - type = DSecret.Type.SecureNote, - ), - ) SendListState( revision = revision, query = queryField, @@ -842,7 +854,6 @@ fun sendListScreenState( sort = comparators .takeIf { queryTrimmed.isEmpty() } .orEmpty(), - primaryActions = primaryActions, saveFilters = null, clearFilters = filters.onClear, clearSort = if (sortDefault != sort) { @@ -855,16 +866,9 @@ fun sendListScreenState( content = content, sideEffects = SendListState.SideEffects(cipherSink), ) - }.combine( - combine( - getCanWrite(), - selectionHandle.idsFlow, - ) { canWrite, itemIds -> - canWrite && itemIds.isEmpty() - }, - ) { state, canWrite -> + }.combine(primaryActionsFlow) { state, actions -> state.copy( - primaryActions = state.primaryActions.takeIf { canWrite }.orEmpty(), + primaryActions = actions, ) }.combine(actionsFlow) { state, actions -> state.copy( diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/add/SendAddRoute.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/add/SendAddRoute.kt new file mode 100644 index 0000000..a44b415 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/add/SendAddRoute.kt @@ -0,0 +1,24 @@ +package com.artemchep.keyguard.feature.send.add + +import androidx.compose.runtime.Composable +import com.artemchep.keyguard.common.model.DSend +import com.artemchep.keyguard.feature.navigation.Route + +class SendAddRoute( + val args: Args, +) : Route { + data class Args( + val type: DSend.Type, + val name: String? = null, + val text: String? = null, + val initialValue: DSend? = null, + val ownershipRo: Boolean = initialValue != null, + ) + + @Composable + override fun Content() { + SendAddScreen( + args = args, + ) + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/add/SendAddScreen.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/add/SendAddScreen.kt new file mode 100644 index 0000000..1a31404 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/add/SendAddScreen.kt @@ -0,0 +1,204 @@ +package com.artemchep.keyguard.feature.send.add + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Save +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +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.common.model.Loadable +import com.artemchep.keyguard.common.model.fold +import com.artemchep.keyguard.common.model.getOrNull +import com.artemchep.keyguard.feature.add.AddScreenItems +import com.artemchep.keyguard.feature.add.AddScreenScope +import com.artemchep.keyguard.feature.add.ToolbarContent +import com.artemchep.keyguard.feature.add.ToolbarContentItemErrSkeleton +import com.artemchep.keyguard.feature.home.vault.add.AddState +import com.artemchep.keyguard.feature.home.vault.component.Section +import com.artemchep.keyguard.feature.navigation.NavigationIcon +import com.artemchep.keyguard.res.Res +import com.artemchep.keyguard.ui.DefaultFab +import com.artemchep.keyguard.ui.ExpandedIfNotEmpty +import com.artemchep.keyguard.ui.FabState +import com.artemchep.keyguard.ui.FlatItemLayout +import com.artemchep.keyguard.ui.FlatSimpleNote +import com.artemchep.keyguard.ui.OptionsButton +import com.artemchep.keyguard.ui.ScaffoldColumn +import com.artemchep.keyguard.ui.button.FavouriteToggleButton +import com.artemchep.keyguard.ui.shimmer.shimmer +import com.artemchep.keyguard.ui.skeleton.SkeletonText +import com.artemchep.keyguard.ui.toolbar.LargeToolbar +import dev.icerock.moko.resources.compose.stringResource + +@Composable +fun SendAddScreen( + args: SendAddRoute.Args, +) { + val loadableState = produceSendAddScreenState( + args = args, + ) + val addScreenScope = remember { + AddScreenScope( + initialFocusRequested = true, + ) + } + + SendAddScreen( + addScreenScope = addScreenScope, + loadableState = loadableState, + ) +} + +@Composable +fun SendAddScreen( + addScreenScope: AddScreenScope, + loadableState: Loadable, +) { + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + ScaffoldColumn( + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection), + topAppBarScrollBehavior = scrollBehavior, + topBar = { + LargeToolbar( + title = { + val title = loadableState.getOrNull()?.title + if (title != null) { + Text(title) + } else { + SkeletonText( + modifier = Modifier + .fillMaxWidth(0.4f), + ) + } + }, + navigationIcon = { + NavigationIcon() + }, + actions = { + val actions = loadableState.getOrNull()?.actions.orEmpty() + OptionsButton( + actions = actions, + ) + }, + scrollBehavior = scrollBehavior, + ) + }, + floatingActionState = run { + val fabOnClick = loadableState.getOrNull()?.onSave + val fabState = if (fabOnClick != null) { + FabState( + onClick = fabOnClick, + model = null, + ) + } else { + null + } + rememberUpdatedState(newValue = fabState) + }, + floatingActionButton = { + DefaultFab( + icon = { + Icon( + imageVector = Icons.Outlined.Save, + contentDescription = null, + ) + }, + text = { + Text( + text = stringResource(Res.strings.save), + ) + }, + ) + }, + columnVerticalArrangement = Arrangement.spacedBy(8.dp), + ) { + populateItems( + addScreenScope = addScreenScope, + loadableState = loadableState, + ) + } +} + +@Composable +private fun ColumnScope.populateItems( + addScreenScope: AddScreenScope, + loadableState: Loadable, +) = loadableState.fold( + ifLoading = { + populateItemsSkeleton( + addScreenScope = addScreenScope, + ) + }, + ifOk = { state -> + populateItemsContent( + addScreenScope = addScreenScope, + state = state, + ) + }, +) + +@Composable +private fun ColumnScope.populateItemsSkeleton( + addScreenScope: AddScreenScope, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = 8.dp, + vertical = 2.dp, + ) + .padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + ) { + ToolbarContentItemErrSkeleton( + modifier = Modifier + .padding(end = 8.dp), + fraction = 0.5f, + ) + } + Section() + Spacer(Modifier.height(24.dp)) + with(addScreenScope) { + AddScreenItems() + } +} + +@Composable +private fun ColumnScope.populateItemsContent( + addScreenScope: AddScreenScope, + state: SendAddState, +) { + ToolbarContent( + modifier = Modifier, + account = state.ownership.ui.account, + organization = state.ownership.ui.organization, + collection = state.ownership.ui.collection, + folder = state.ownership.ui.folder, + onClick = state.ownership.ui.onClick, + ) + Section() + Spacer(Modifier.height(24.dp)) + with(addScreenScope) { + AddScreenItems( + items = state.items, + ) + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/add/SendAddState.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/add/SendAddState.kt new file mode 100644 index 0000000..8570812 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/add/SendAddState.kt @@ -0,0 +1,22 @@ +package com.artemchep.keyguard.feature.send.add + +import com.artemchep.keyguard.feature.add.AddStateItem +import com.artemchep.keyguard.feature.add.AddStateOwnership +import com.artemchep.keyguard.ui.FlatItemAction + +data class SendAddState( + val title: String = "", + val ownership: Ownership, + val actions: List = emptyList(), + val items: List = emptyList(), + val onSave: (() -> Unit)? = null, +) { + data class Ownership( + val data: Data, + val ui: AddStateOwnership, + ) { + data class Data( + val accountId: String?, + ) + } +} \ No newline at end of file diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/add/SendAddStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/add/SendAddStateProducer.kt new file mode 100644 index 0000000..6d10264 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/add/SendAddStateProducer.kt @@ -0,0 +1,723 @@ +package com.artemchep.keyguard.feature.send.add + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AccountBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.text.input.KeyboardType +import arrow.core.flatten +import arrow.core.partially2 +import arrow.optics.Optional +import com.artemchep.keyguard.common.io.attempt +import com.artemchep.keyguard.common.io.bind +import com.artemchep.keyguard.common.io.effectTap +import com.artemchep.keyguard.common.io.ioEffect +import com.artemchep.keyguard.common.io.launchIn +import com.artemchep.keyguard.common.io.toIO +import com.artemchep.keyguard.common.model.DSecret +import com.artemchep.keyguard.common.model.DSend +import com.artemchep.keyguard.common.model.Loadable +import com.artemchep.keyguard.common.model.create.CreateRequest +import com.artemchep.keyguard.common.model.create.CreateSendRequest +import com.artemchep.keyguard.common.model.create.address1 +import com.artemchep.keyguard.common.model.create.address2 +import com.artemchep.keyguard.common.model.create.address3 +import com.artemchep.keyguard.common.model.create.city +import com.artemchep.keyguard.common.model.create.company +import com.artemchep.keyguard.common.model.create.country +import com.artemchep.keyguard.common.model.create.email +import com.artemchep.keyguard.common.model.create.firstName +import com.artemchep.keyguard.common.model.create.identity +import com.artemchep.keyguard.common.model.create.lastName +import com.artemchep.keyguard.common.model.create.licenseNumber +import com.artemchep.keyguard.common.model.create.middleName +import com.artemchep.keyguard.common.model.create.note +import com.artemchep.keyguard.common.model.create.passportNumber +import com.artemchep.keyguard.common.model.create.phone +import com.artemchep.keyguard.common.model.create.postalCode +import com.artemchep.keyguard.common.model.create.ssn +import com.artemchep.keyguard.common.model.create.state +import com.artemchep.keyguard.common.model.create.text +import com.artemchep.keyguard.common.model.create.title +import com.artemchep.keyguard.common.model.create.username +import com.artemchep.keyguard.common.model.titleH +import com.artemchep.keyguard.common.service.clipboard.ClipboardService +import com.artemchep.keyguard.common.service.logging.LogRepository +import com.artemchep.keyguard.common.usecase.AddSend +import com.artemchep.keyguard.common.usecase.CipherUnsecureUrlCheck +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.GetGravatarUrl +import com.artemchep.keyguard.common.usecase.GetMarkdown +import com.artemchep.keyguard.common.usecase.GetOrganizations +import com.artemchep.keyguard.common.usecase.GetProfiles +import com.artemchep.keyguard.common.usecase.GetSends +import com.artemchep.keyguard.common.usecase.GetTotpCode +import com.artemchep.keyguard.common.usecase.ShowMessage +import com.artemchep.keyguard.common.util.flow.combineToList +import com.artemchep.keyguard.common.util.flow.persistingStateIn +import com.artemchep.keyguard.feature.add.AddStateItem +import com.artemchep.keyguard.feature.add.AddStateOwnership +import com.artemchep.keyguard.feature.add.LocalStateItem +import com.artemchep.keyguard.feature.auth.common.SwitchFieldModel +import com.artemchep.keyguard.feature.auth.common.TextFieldModel2 +import com.artemchep.keyguard.feature.auth.common.util.validatedTitle +import com.artemchep.keyguard.feature.confirmation.organization.OrganizationConfirmationResult +import com.artemchep.keyguard.feature.confirmation.organization.OrganizationConfirmationRoute +import com.artemchep.keyguard.feature.home.vault.add.AddRoute +import com.artemchep.keyguard.feature.home.vault.add.TmpIdentity +import com.artemchep.keyguard.feature.home.vault.add.createItem +import com.artemchep.keyguard.feature.navigation.NavigationIntent +import com.artemchep.keyguard.feature.navigation.registerRouteResultReceiver +import com.artemchep.keyguard.feature.navigation.state.PersistedStorage +import com.artemchep.keyguard.feature.navigation.state.RememberStateFlowScope +import com.artemchep.keyguard.feature.navigation.state.copy +import com.artemchep.keyguard.feature.navigation.state.produceScreenState +import com.artemchep.keyguard.feature.send.view.SendViewRoute +import com.artemchep.keyguard.platform.parcelize.LeParcelable +import com.artemchep.keyguard.platform.parcelize.LeParcelize +import com.artemchep.keyguard.res.Res +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import org.kodein.di.compose.localDI +import org.kodein.di.direct +import org.kodein.di.instance + +@kotlinx.serialization.Serializable +@LeParcelize +data class SendAddOwnershipData( + val accountId: String?, +) : LeParcelable + +data class TmpText( + val text: AddStateItem.Text, + val items: List, +) + +data class TmpOptions( + val deletionDate: AddStateItem.DateTime, + val expirationDate: AddStateItem.DateTime, + val password: AddStateItem.Password, + val items: List, +) + +data class TmpNote( + val note: AddStateItem.Note, + val items: List, +) + +@Composable +fun produceSendAddScreenState( + args: SendAddRoute.Args, +) = with(localDI().direct) { + produceSendAddScreenState( + args = args, + getAccounts = instance(), + getProfiles = instance(), + getOrganizations = instance(), + getCollections = instance(), + getFolders = instance(), + getCiphers = instance(), + getSends = instance(), + getTotpCode = instance(), + getGravatarUrl = instance(), + getMarkdown = instance(), + logRepository = instance(), + clipboardService = instance(), + cipherUnsecureUrlCheck = instance(), + showMessage = instance(), + addSend = instance(), + ) +} + +@Composable +fun produceSendAddScreenState( + args: SendAddRoute.Args, + getAccounts: GetAccounts, + getProfiles: GetProfiles, + getOrganizations: GetOrganizations, + getCollections: GetCollections, + getFolders: GetFolders, + getCiphers: GetCiphers, + getSends: GetSends, + getTotpCode: GetTotpCode, + getGravatarUrl: GetGravatarUrl, + getMarkdown: GetMarkdown, + logRepository: LogRepository, + clipboardService: ClipboardService, + cipherUnsecureUrlCheck: CipherUnsecureUrlCheck, + showMessage: ShowMessage, + addSend: AddSend, +): Loadable = produceScreenState( + key = "send_add", + initial = Loadable.Loading, + args = arrayOf( + args, + getAccounts, + getOrganizations, + getCollections, + getFolders, + getCiphers, + getTotpCode, + ), +) { + val copyText = copy(clipboardService) + val markdown = getMarkdown().first() + + val title = if (args.ownershipRo) { + translate(Res.strings.addsend_header_edit_title) + } else { + translate(Res.strings.addsend_header_new_title) + } + + val ownershipFlow = produceOwnershipFlow( + args = args, + getProfiles = getProfiles, + getSends = getSends, + ) + + val textHolder = produceTextState( + args = args, + ) + val noteHolder = produceNoteState( + args = args, + markdown = markdown, + ) + + val typeFlow = kotlin.run { + val initialValue = args.type + ?: args.initialValue?.type + // this should never happen + ?: DSend.Type.None + flowOf(initialValue) + } + val typeItemsFlow = typeFlow + .flatMapLatest { type -> + when (type) { + DSend.Type.Text -> flowOf(textHolder.items) + DSend.Type.File -> flowOf(emptyList()) + DSend.Type.None -> flowOf(emptyList()) + } + } + val miscItems by lazy { + listOf( + AddStateItem.Section( + id = "misc", + ), + AddStateItem.Note( + id = "misc.note", + state = noteHolder.note.state, + markdown = markdown, + ), + ) + } + val miscFlow = typeFlow + .map { _ -> + val hasNotes = false + hasNotes + } + .distinctUntilChanged() + .map { hasNotes -> + miscItems.takeUnless { hasNotes }.orEmpty() + } + + val titleItem = AddStateItem.Title( + id = "title", + state = LocalStateItem( + flow = kotlin.run { + val key = "title" + val sink = mutablePersistedFlow(key) { + args.initialValue?.name + ?: "" + } + val state = asComposeState(key) + combine( + sink + .validatedTitle(this), + typeFlow, + ) { validatedTitle, type -> + TextFieldModel2.of( + state = state, + hint = translate(type.titleH()), + validated = validatedTitle, + onChange = state::value::set, + ) + } + .persistingStateIn( + scope = screenScope, + started = SharingStarted.WhileSubscribed(1000L), + initialValue = TextFieldModel2.empty, + ) + }, + populator = { field -> + copy( + title = field.text, + ) + }, + ), + ) + val items1 = listOfNotNull( + titleItem, + ) + + fun stetify(flow: Flow>) = flow + .map { items -> + items + .mapNotNull { item -> + val stateHolder = item as? AddStateItem.HasState + ?: return@mapNotNull null + + val state = stateHolder.state + val flow = state.flow + .map { v -> + state.populator + .partially2(v) + } + item.id to flow + } + } + + val itfff = combine( + typeItemsFlow, + miscFlow, + ) { arr -> + arr.toList().flatten() + } + .onEach { l -> + logRepository.post("Foo3", "combine ${l.size}") + } + + val outputFlow = combine( + stetify(itfff), + stetify(flowOf(items1)), + ) { arr -> + arr + .flatMap { + it + } + } + .flatMapLatest { populatorFlows -> + val typePopulator = + typeFlow + .map { type -> + val f = fun(r: CreateSendRequest): CreateSendRequest { + return r.copy(type = type) + } + f + } + val ownershipPopulator = + ownershipFlow + .map { ownership -> + val f = fun(r: CreateSendRequest): CreateSendRequest { + val requestOwnership = CreateSendRequest.Ownership( + accountId = ownership.data.accountId, + ) + return r.copy(ownership = requestOwnership) + } + f + } + (populatorFlows.map { it.second } + ownershipPopulator + typePopulator) + .combineToList() + } + .map { populators -> + populators.fold( + initial = CreateSendRequest( + now = Clock.System.now(), + ), + ) { y, x -> x(y) } + } + .distinctUntilChanged() + + + val items = listOf( + AddStateItem.Title( + id = "title", + state = LocalStateItem( + flow = MutableStateFlow(TextFieldModel2(mutableStateOf(""))), + ), + ), + AddStateItem.Text( + id = "text", + state = LocalStateItem( + flow = MutableStateFlow( + value = AddStateItem.Text.State( + value = TextFieldModel2.empty, + label = "Text", + singleLine = false, + ), + ), + ), + ), + AddStateItem.Switch( + id = "text_switch", + title = "Conceal text by default", + state = LocalStateItem( + flow = MutableStateFlow( + value = SwitchFieldModel( + checked = false, + onChange = {}, + ), + ), + ), + ), + ) + combine( + ownershipFlow, + outputFlow, + itfff, + ) { ownership, output, ddd -> + val state = SendAddState( + title = title, + ownership = ownership, + items = items1 + ddd, + onSave = { + val request = CreateSendRequest( + ownership = CreateSendRequest.Ownership( + accountId = ownership.data.accountId, + ), + title = "title", + note = "note", + // types + type = DSend.Type.Text, + text = CreateSendRequest.Text( + text = "text", + ), + // other + now = Clock.System.now(), + ) + val sendIdToRequestMap = mapOf( + args.initialValue?.id?.takeIf { args.ownershipRo } to request, + ) + addSend(sendIdToRequestMap) + .effectTap { + val intent = kotlin.run { + val list = mutableListOf() + list += NavigationIntent.PopById(screenId, exclusive = false) + if (true) { // TODO: args.behavior.launchEditedCipher + val sendId = it.first() + val accountId = ownership.data.accountId!! + val route = SendViewRoute( + sendId = sendId, + accountId = accountId, + ) + list += NavigationIntent.NavigateToRoute(route) + } + NavigationIntent.Composite( + list = list, + ) + } + navigate(intent) + } + .launchIn(appScope) + }, + ) + Loadable.Ok(state) + } +} + +private suspend fun RememberStateFlowScope.produceOwnershipFlow( + args: SendAddRoute.Args, + getProfiles: GetProfiles, + getSends: GetSends, +): Flow { + val ro = args.ownershipRo + + data class Fool( + val value: T, + val element: AddStateOwnership.Element?, + ) + + val disk = loadDiskHandle("new_send") + val accountIdSink = mutablePersistedFlow( + key = "ownership.account_id", + storage = PersistedStorage.InDisk(disk), + ) { null } + + val initialState = kotlin.run { + if (args.initialValue != null) { + return@run SendAddOwnershipData( + accountId = args.initialValue.accountId, + ) + } + + // Make an account that has the most ciphers a + // default account. + val accountId = ioEffect { + val accountIds = getProfiles().toIO().bind() + .map { it.accountId() } + .toSet() + + fun String.takeIfAccountIdExists() = this + .takeIf { id -> + id in accountIds + } + + val lastAccountId = accountIdSink.value?.takeIfAccountIdExists() + if (lastAccountId != null) { + return@ioEffect lastAccountId + } + + val ciphers = getSends().toIO().bind() + ciphers + .asSequence() + .groupBy { it.accountId } + // the one that has the most sends + .maxByOrNull { entry -> entry.value.size } + // account id + ?.key + ?.takeIfAccountIdExists() + }.attempt().bind().getOrNull() + + SendAddOwnershipData( + accountId = accountId, + ) + } + val sink = mutablePersistedFlow("ownership") { + initialState + } + + // If we are creating a new item, then remember the + // last selected account to pre-select it next time. + // TODO: Remember only when an item is created. + sink + .map { it.accountId } + .onEach(accountIdSink::value::set) + .launchIn(screenScope) + + val accountFlow = combine( + sink + .map { it.accountId } + .distinctUntilChanged(), + getProfiles(), + ) { accountId, profiles -> + if (accountId == null) { + val item = AddStateOwnership.Element.Item( + key = "account.empty", + title = translate(Res.strings.account_none), + stub = true, + ) + val el = AddStateOwnership.Element( + readOnly = ro, + items = listOf(item), + ) + return@combine Fool( + value = null, + element = el, + ) + } + val profileOrNull = profiles + .firstOrNull { it.accountId() == accountId } + val el = AddStateOwnership.Element( + readOnly = ro, + items = listOfNotNull(profileOrNull) + .map { account -> + val key = "account.${account.accountId()}" + AddStateOwnership.Element.Item( + key = key, + title = account.email, + text = account.accountHost, + accentColors = account.accentColor, + ) + }, + ) + Fool( + value = accountId, + element = el, + ) + } + + return combine( + accountFlow, + ) { (account) -> + val flags = if (ro) { + OrganizationConfirmationRoute.Args.RO_ACCOUNT + } else { + 0 + } or + OrganizationConfirmationRoute.Args.HIDE_ORGANIZATION or + OrganizationConfirmationRoute.Args.HIDE_COLLECTION or + OrganizationConfirmationRoute.Args.HIDE_FOLDER or + OrganizationConfirmationRoute.Args.PREMIUM_ACCOUNT + val data = SendAddState.Ownership.Data( + accountId = account.value, + ) + val ui = AddStateOwnership( + account = account.element, + onClick = { + val route = registerRouteResultReceiver( + route = OrganizationConfirmationRoute( + args = OrganizationConfirmationRoute.Args( + decor = OrganizationConfirmationRoute.Args.Decor( + title = translate(Res.strings.save_to), + icon = Icons.Outlined.AccountBox, + ), + flags = flags, + accountId = account.value, + ), + ), + ) { result -> + if (result is OrganizationConfirmationResult.Confirm) { + val newState = SendAddOwnershipData( + accountId = result.accountId, + ) + sink.value = newState + } + } + val intent = NavigationIntent.NavigateToRoute(route) + navigate(intent) + }, + ) + SendAddState.Ownership( + data = data, + ui = ui, + ) + } +} + +private suspend fun RememberStateFlowScope.produceTextState( + args: SendAddRoute.Args, +): TmpText { + val prefix = "identity" + + suspend fun createItem( + key: String, + label: String? = null, + initialValue: String? = null, + singleLine: Boolean = false, + autocompleteOptions: ImmutableList = persistentListOf(), + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + lens: Optional, + ) = createItem( + prefix = prefix, + key = key, + label = label, + initialValue = initialValue, + singleLine = singleLine, + autocompleteOptions = autocompleteOptions, + keyboardOptions = keyboardOptions, + populator = { + lens.set(this, it.value.text) + }, + ) + + val txt = args.initialValue?.text + val text = createItem( + key = "text", + label = translate(Res.strings.identity_title), + initialValue = txt?.text, + singleLine = true, + lens = CreateSendRequest.text.text, + ) + return TmpText( + text = text, + items = listOfNotNull( + text, + ), + ) +} + +private suspend fun RememberStateFlowScope.produceOptionsState( + args: SendAddRoute.Args, +): TmpText { + val prefix = "options" + + suspend fun createItem( + key: String, + label: String? = null, + initialValue: String? = null, + singleLine: Boolean = false, + autocompleteOptions: ImmutableList = persistentListOf(), + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + lens: Optional, + ) = createItem( + prefix = prefix, + key = key, + label = label, + initialValue = initialValue, + singleLine = singleLine, + autocompleteOptions = autocompleteOptions, + keyboardOptions = keyboardOptions, + populator = { + lens.set(this, it.value.text) + }, + ) + + val txt = args.initialValue?.text + val text = createItem( + key = "text", + label = translate(Res.strings.identity_title), + initialValue = txt?.text, + singleLine = true, + lens = CreateSendRequest.text.text, + ) + return TmpText( + text = text, + items = listOfNotNull( + text, + ), + ) +} + +private suspend fun RememberStateFlowScope.produceNoteState( + args: SendAddRoute.Args, + markdown: Boolean, +): TmpNote { + val prefix = "notes" + + val note = kotlin.run { + val id = "$prefix.note" + + val initialValue = args.initialValue?.notes + + val sink = mutablePersistedFlow(id) { initialValue.orEmpty() } + val state = mutableComposeState(sink) + val stateItem = LocalStateItem( + flow = sink + .map { value -> + val model = TextFieldModel2( + state = state, + text = value, + hint = "Add any notes about this item here", + onChange = state::value::set, + ) + model + } + .persistingStateIn( + scope = screenScope, + started = SharingStarted.WhileSubscribed(), + initialValue = TextFieldModel2.empty, + ), + populator = { state -> + CreateSendRequest.note.set(this, state.text) + }, + ) + AddStateItem.Note( + id = id, + state = stateItem, + markdown = markdown, + ) + } + return TmpNote( + note = note, + items = listOfNotNull( + note, + ), + ) +} 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 6ec9572..ef7dae4 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 @@ -1,13 +1,15 @@ package com.artemchep.keyguard.feature.send.list +import androidx.compose.animation.Crossfade import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -15,11 +17,14 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AccessTime +import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Key import androidx.compose.material.icons.outlined.PersonAdd import androidx.compose.material.pullrefresh.PullRefreshState import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LocalAbsoluteTonalElevation @@ -50,7 +55,6 @@ import com.artemchep.keyguard.common.model.DSend import com.artemchep.keyguard.common.model.expiredFlow import com.artemchep.keyguard.feature.EmptySearchView import com.artemchep.keyguard.feature.home.vault.component.FlatItemLayout2 -import com.artemchep.keyguard.feature.home.vault.component.SearchTextField import com.artemchep.keyguard.feature.home.vault.component.Section import com.artemchep.keyguard.feature.home.vault.component.rememberSecretAccentColor import com.artemchep.keyguard.feature.home.vault.component.surfaceColorAtElevationSemi @@ -74,8 +78,15 @@ import com.artemchep.keyguard.ui.AvatarBadgeIcon import com.artemchep.keyguard.ui.AvatarBuilder import com.artemchep.keyguard.ui.CollectedEffect import com.artemchep.keyguard.ui.Compose +import com.artemchep.keyguard.ui.DefaultFab +import com.artemchep.keyguard.ui.DefaultSelection +import com.artemchep.keyguard.ui.DropdownMenuItemFlat +import com.artemchep.keyguard.ui.DropdownMinWidth +import com.artemchep.keyguard.ui.DropdownScopeImpl import com.artemchep.keyguard.ui.ExpandedIfNotEmptyForRow +import com.artemchep.keyguard.ui.FabState import com.artemchep.keyguard.ui.FlatItem +import com.artemchep.keyguard.ui.FlatItemAction import com.artemchep.keyguard.ui.FlatItemTextContent import com.artemchep.keyguard.ui.MediumEmphasisAlpha import com.artemchep.keyguard.ui.OptionsButton @@ -83,6 +94,7 @@ 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.icons.IconBox import com.artemchep.keyguard.ui.icons.KeyguardNote import com.artemchep.keyguard.ui.icons.KeyguardView import com.artemchep.keyguard.ui.pulltosearch.PullToSearch @@ -224,6 +236,12 @@ private fun SendScreenContent( pullRefreshState: PullRefreshState, scrollBehavior: TopAppBarScrollBehavior, ) { + val dp = remember { + mutableStateOf(false) + } + if (state.primaryActions.isEmpty()) { + dp.value = false + } ScaffoldLazyColumn( modifier = modifier .pullRefresh(pullRefreshState) @@ -263,7 +281,77 @@ private fun SendScreenContent( ) } }, + floatingActionState = run { + val fabVisible = state.primaryActions.isNotEmpty() + val fabState = if (fabVisible) { + // If there's only one primary action, then there's no + // need to show the dropdown. + val onClick = run { + val action = + state.primaryActions.firstNotNullOfOrNull { it as? FlatItemAction } + action?.onClick + ?.takeIf { + val count = state.primaryActions + .count { it is FlatItemAction } + count == 1 + } + } ?: + // lambda + { + dp.value = true + } + FabState( + onClick = onClick, + model = null, + ) + } else { + null + } + rememberUpdatedState(newValue = fabState) + }, + floatingActionButton = { + DefaultFab( + icon = { + IconBox(main = Icons.Outlined.Add) + + // Inject the dropdown popup to the bottom of the + // content. + val onDismissRequest = remember(dp) { + // lambda + { + dp.value = false + } + } + DropdownMenu( + modifier = Modifier + .widthIn(min = DropdownMinWidth), + expanded = dp.value, + onDismissRequest = onDismissRequest, + ) { + val scope = DropdownScopeImpl(this, onDismissRequest = onDismissRequest) + with(scope) { + state.primaryActions.forEachIndexed { index, action -> + DropdownMenuItemFlat( + action = action, + ) + } + } + } + }, + text = { + Text( + text = stringResource(Res.strings.send_main_new_item_button), + ) + }, + ) + }, pullRefreshState = pullRefreshState, + bottomBar = { + val selectionOrNull = (state.content as? SendListState.Content.Items)?.selection + DefaultSelection( + state = selectionOrNull, + ) + }, overlay = { PullToSearch( modifier = Modifier @@ -602,7 +690,33 @@ fun VaultSendItemText( Spacer(modifier = Modifier.width(8.dp)) - ChevronIcon() + val showCheckbox = when { + localState.selectableItemState.selecting -> true + else -> false + } + Crossfade( + modifier = Modifier + .size( + width = 36.dp, + height = 36.dp, + ), + targetState = showCheckbox, + ) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + if (showCheckbox) { + Checkbox( + checked = localState.selectableItemState.selected, + onCheckedChange = null, + ) + } else { + ChevronIcon() + } + } + } }, onClick = onClick, onLongClick = onLongClick, diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/search/SendListFilter.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/search/SendListFilter.kt index 5e273ce..d3a6404 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/search/SendListFilter.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/search/SendListFilter.kt @@ -188,7 +188,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() } diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/util/SendUtil.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/util/SendUtil.kt new file mode 100644 index 0000000..092e4af --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/util/SendUtil.kt @@ -0,0 +1,758 @@ +package com.artemchep.keyguard.feature.send.util + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Attachment +import androidx.compose.material.icons.outlined.DeleteForever +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Password +import androidx.compose.material.icons.outlined.Remove +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import arrow.core.some +import com.artemchep.keyguard.common.io.launchIn +import com.artemchep.keyguard.common.model.DProfile +import com.artemchep.keyguard.common.model.DSend +import com.artemchep.keyguard.common.model.PatchSendRequest +import com.artemchep.keyguard.common.usecase.PatchSendById +import com.artemchep.keyguard.common.usecase.RemoveSendById +import com.artemchep.keyguard.common.usecase.SendToolbox +import com.artemchep.keyguard.common.util.StringComparatorIgnoreCase +import com.artemchep.keyguard.feature.confirmation.ConfirmationResult +import com.artemchep.keyguard.feature.confirmation.ConfirmationRoute +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.platform.util.isRelease +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.Stub +import com.artemchep.keyguard.ui.icons.icon +import com.artemchep.keyguard.ui.icons.iconSmall +import com.artemchep.keyguard.ui.selection.SelectionHandle +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 + +object SendUtil { + context(RememberStateFlowScope) + fun renameActionOrNull( + patchSendById: PatchSendById, + sends: List, + canEdit: Boolean, + before: (() -> Unit)? = null, + after: ((Boolean) -> Unit)? = null, + ) = kotlin.run { + if (!canEdit) { + return@run null + } + + val icon = icon(Icons.Outlined.Edit) + val title = if (sends.size > 1) { + translate(Res.strings.sends_action_change_names_title) + } else { + translate(Res.strings.sends_action_change_name_title) + } + FlatItemAction( + leading = icon, + title = title, + onClick = { + before?.invoke() + + val items = sends + .sorted() + .map { cipher -> + ConfirmationRoute.Args.Item.StringItem( + key = cipher.id, + value = cipher.name, + title = cipher.name, + type = ConfirmationRoute.Args.Item.StringItem.Type.Text, + canBeEmpty = false, + ) + } + val route = registerRouteResultReceiver( + route = ConfirmationRoute( + args = ConfirmationRoute.Args( + icon = icon, + title = title, + items = items, + ), + ), + ) { result -> + if (result is ConfirmationResult.Confirm) { + val request = createPatchRequestMultiple(result.data) { name -> + val nameFixed = name as String + PatchSendRequest.Data( + name = nameFixed.some(), + ) + } + patchSendById(request) + .launchIn(appScope) + } + + val success = result is ConfirmationResult.Confirm + after?.invoke(success) + } + val intent = NavigationIntent.NavigateToRoute(route) + navigate(intent) + }, + ) + } + + context(RememberStateFlowScope) + fun renameFileActionOrNull( + patchSendById: PatchSendById, + sends: List, + canEdit: Boolean, + before: (() -> Unit)? = null, + after: ((Boolean) -> Unit)? = null, + ) = kotlin.run { + if (!canEdit) { + return@run null + } + + val allFiles = sends.all { it.file != null } + if (!allFiles) { + return@run null + } + + // TODO: Seems like at this moment we can not change the file name + if (isRelease) return@run null + + val icon = iconSmall(Icons.Outlined.Attachment, Icons.Outlined.Edit) + val title = if (sends.size > 1) { + translate(Res.strings.sends_action_change_filenames_title) + } else { + translate(Res.strings.sends_action_change_filename_title) + } + FlatItemAction( + leading = icon, + title = title, + onClick = { + before?.invoke() + + val items = sends + .sorted() + .map { cipher -> + ConfirmationRoute.Args.Item.StringItem( + key = cipher.id, + value = cipher.file?.fileName.orEmpty(), + title = cipher.name, + type = ConfirmationRoute.Args.Item.StringItem.Type.Text, + canBeEmpty = false, + ) + } + val route = registerRouteResultReceiver( + route = ConfirmationRoute( + args = ConfirmationRoute.Args( + icon = icon, + title = title, + items = items, + ), + ), + ) { result -> + if (result is ConfirmationResult.Confirm) { + val request = createPatchRequestMultiple(result.data) { name -> + val nameFixed = name as String + PatchSendRequest.Data( + fileName = nameFixed.some(), + ) + } + patchSendById(request) + .launchIn(appScope) + } + + val success = result is ConfirmationResult.Confirm + after?.invoke(success) + } + val intent = NavigationIntent.NavigateToRoute(route) + navigate(intent) + }, + ) + } + + context(RememberStateFlowScope) + fun changePasswordActionOrNull( + patchSendById: PatchSendById, + sends: List, + canEdit: Boolean, + before: (() -> Unit)? = null, + after: ((Boolean) -> Unit)? = null, + ) = kotlin.run { + if (!canEdit) { + return@run null + } + + val hasPasswords = sends.any { it.hasPassword } + + val icon = iconSmall(Icons.Outlined.Password) + val title = if (hasPasswords) { + if (sends.size > 1) { + translate(Res.strings.sends_action_change_passwords_title) + } else { + translate(Res.strings.sends_action_change_password_title) + } + } else { + if (sends.size > 1) { + translate(Res.strings.sends_action_set_passwords_title) + } else { + translate(Res.strings.sends_action_set_password_title) + } + } + FlatItemAction( + leading = icon, + title = title, + onClick = { + before?.invoke() + + val items = sends + .sorted() + .map { send -> + ConfirmationRoute.Args.Item.StringItem( + key = send.id, + value = "", + title = send.name, + type = ConfirmationRoute.Args.Item.StringItem.Type.Password, + canBeEmpty = true, // so you can clear passwords + ) + } + val route = registerRouteResultReceiver( + route = ConfirmationRoute( + args = ConfirmationRoute.Args( + icon = icon, + title = title, + message = if (hasPasswords) { + null + } else { + translate(Res.strings.sends_action_set_password_confirmation_message) + }, + items = items, + ), + ), + ) { result -> + if (result is ConfirmationResult.Confirm) { + val request = createPatchRequestMultiple(result.data) { password -> + val passwordFixed = (password as String) + .takeIf { it.isNotEmpty() } + PatchSendRequest.Data( + password = passwordFixed.some(), + ) + } + patchSendById(request) + .launchIn(appScope) + } + + val success = result is ConfirmationResult.Confirm + after?.invoke(success) + } + val intent = NavigationIntent.NavigateToRoute(route) + navigate(intent) + }, + ) + } + + context(RememberStateFlowScope) + fun removePasswordActionOrNull( + patchSendById: PatchSendById, + sends: List, + canEdit: Boolean, + before: (() -> Unit)? = null, + after: ((Boolean) -> Unit)? = null, + ) = kotlin.run { + if (!canEdit) { + return@run null + } + + val hasPasswords = sends.any { it.hasPassword } + if (!hasPasswords) { + return@run null + } + + val icon = iconSmall(Icons.Outlined.Password, Icons.Outlined.Remove) + val title = if (sends.size > 1) { + translate(Res.strings.sends_action_remove_passwords_title) + } else { + translate(Res.strings.sends_action_remove_password_title) + } + FlatItemAction( + leading = icon, + title = title, + onClick = { + before?.invoke() + + val route = registerRouteResultReceiver( + route = ConfirmationRoute( + args = ConfirmationRoute.Args( + icon = icon(Icons.Outlined.Password, Icons.Outlined.Remove), + title = if (sends.size > 1) { + translate(Res.strings.sends_action_remove_passwords_confirmation_title) + } else { + translate(Res.strings.sends_action_remove_password_confirmation_title) + }, + message = translate(Res.strings.sends_action_remove_password_confirmation_message), + ), + ), + ) { result -> + if (result is ConfirmationResult.Confirm) { + val request = createPatchRequestSingle(sends) { + PatchSendRequest.Data( + password = null.some(), + ) + } + patchSendById(request) + .launchIn(appScope) + } + + val success = result is ConfirmationResult.Confirm + after?.invoke(success) + } + val intent = NavigationIntent.NavigateToRoute(route) + navigate(intent) + }, + ) + } + + context(RememberStateFlowScope) + fun showEmailActionOrNull( + patchSendById: PatchSendById, + sends: List, + canEdit: Boolean, + before: (() -> Unit)? = null, + after: ((Boolean) -> Unit)? = null, + ) = kotlin.run { + if (!canEdit) { + return@run null + } + val canShowEmail = sends.any { it.hideEmail } + if (!canShowEmail) { + return@run null + } + showEmailAction( + patchSendById = patchSendById, + sends = sends, + before = before, + after = after, + ) + } + + context(RememberStateFlowScope) + fun showEmailAction( + patchSendById: PatchSendById, + sends: List, + before: (() -> Unit)? = null, + after: ((Boolean) -> Unit)? = null, + ) = kotlin.run { + val icon = iconSmall(Icons.Outlined.Email, Icons.Outlined.Visibility) + val title = translate(Res.strings.sends_action_show_email_title) + FlatItemAction( + leading = icon, + title = title, + onClick = { + before?.invoke() + + val request = createPatchRequestSingle(sends) { + PatchSendRequest.Data( + hideEmail = false.some(), + ) + } + patchSendById(request) + .launchIn(appScope) + }, + ) + } + + context(RememberStateFlowScope) + fun hideEmailActionOrNull( + patchSendById: PatchSendById, + sends: List, + canEdit: Boolean, + before: (() -> Unit)? = null, + after: ((Boolean) -> Unit)? = null, + ) = kotlin.run { + if (!canEdit) { + return@run null + } + val canHideEmail = sends.any { !it.hideEmail } + if (!canHideEmail) { + return@run null + } + hideEmailAction( + patchSendById = patchSendById, + sends = sends, + before = before, + after = after, + ) + } + + context(RememberStateFlowScope) + fun hideEmailAction( + patchSendById: PatchSendById, + sends: List, + before: (() -> Unit)? = null, + after: ((Boolean) -> Unit)? = null, + ) = kotlin.run { + val icon = iconSmall(Icons.Outlined.Email, Icons.Outlined.VisibilityOff) + val title = translate(Res.strings.sends_action_hide_email_title) + FlatItemAction( + leading = icon, + title = title, + onClick = { + before?.invoke() + + val request = createPatchRequestSingle(sends) { + PatchSendRequest.Data( + hideEmail = true.some(), + ) + } + patchSendById(request) + .launchIn(appScope) + }, + ) + } + + context(RememberStateFlowScope) + fun enableActionOrNull( + patchSendById: PatchSendById, + sends: List, + canEdit: Boolean, + before: (() -> Unit)? = null, + after: ((Boolean) -> Unit)? = null, + ) = kotlin.run { + if (!canEdit) { + return@run null + } + val canEnable = sends.any { it.disabled } + if (!canEnable) { + return@run null + } + enableAction( + patchSendById = patchSendById, + sends = sends, + before = before, + after = after, + ) + } + + context(RememberStateFlowScope) + fun enableAction( + patchSendById: PatchSendById, + sends: List, + before: (() -> Unit)? = null, + after: ((Boolean) -> Unit)? = null, + ) = kotlin.run { + val icon = icon(Icons.Stub) + val title = translate(Res.strings.sends_action_enable_title) + val text = translate(Res.strings.sends_action_enable_text) + FlatItemAction( + leading = icon, + title = title, + text = text, + onClick = { + before?.invoke() + + val route = registerRouteResultReceiver( + route = ConfirmationRoute( + args = ConfirmationRoute.Args( + title = translate(Res.strings.sends_action_enable_confirmation_title), + ), + ), + ) { result -> + if (result is ConfirmationResult.Confirm) { + val request = createPatchRequestSingle(sends) { + PatchSendRequest.Data( + disabled = false.some(), + ) + } + patchSendById(request) + .launchIn(appScope) + } + + val success = result is ConfirmationResult.Confirm + after?.invoke(success) + } + val intent = NavigationIntent.NavigateToRoute(route) + navigate(intent) + }, + ) + } + + context(RememberStateFlowScope) + fun disableActionOrNull( + patchSendById: PatchSendById, + sends: List, + canEdit: Boolean, + before: (() -> Unit)? = null, + after: ((Boolean) -> Unit)? = null, + ) = kotlin.run { + if (!canEdit) { + return@run null + } + val canDisable = sends.any { !it.disabled } + if (!canDisable) { + return@run null + } + disableAction( + patchSendById = patchSendById, + sends = sends, + before = before, + after = after, + ) + } + + context(RememberStateFlowScope) + fun disableAction( + patchSendById: PatchSendById, + sends: List, + before: (() -> Unit)? = null, + after: ((Boolean) -> Unit)? = null, + ) = kotlin.run { + val icon = icon(Icons.Stub) + val title = translate(Res.strings.sends_action_disable_title) + val text = translate(Res.strings.sends_action_disable_text) + FlatItemAction( + leading = icon, + title = title, + text = text, + onClick = { + before?.invoke() + + val route = registerRouteResultReceiver( + route = ConfirmationRoute( + args = ConfirmationRoute.Args( + title = translate(Res.strings.sends_action_disable_confirmation_title), + ), + ), + ) { result -> + if (result is ConfirmationResult.Confirm) { + val request = createPatchRequestSingle(sends) { + PatchSendRequest.Data( + disabled = true.some(), + ) + } + patchSendById(request) + .launchIn(appScope) + } + + val success = result is ConfirmationResult.Confirm + after?.invoke(success) + } + val intent = NavigationIntent.NavigateToRoute(route) + navigate(intent) + }, + ) + } + + context(RememberStateFlowScope) + fun deleteActionOrNull( + removeSendById: RemoveSendById, + sends: List, + canDelete: Boolean, + before: (() -> Unit)? = null, + after: ((Boolean) -> Unit)? = null, + ) = kotlin.run { + if (!canDelete) { + return@run null + } + deleteAction( + removeSendById = removeSendById, + sends = sends, + before = before, + after = after, + ) + } + + context(RememberStateFlowScope) + fun deleteAction( + removeSendById: RemoveSendById, + sends: List, + before: (() -> Unit)? = null, + after: ((Boolean) -> Unit)? = null, + ) = kotlin.run { + val icon = icon(Icons.Outlined.DeleteForever) + val title = translate(Res.strings.sends_action_delete_title) + FlatItemAction( + leading = icon, + title = title, + onClick = { + before?.invoke() + + val route = registerRouteResultReceiver( + route = ConfirmationRoute( + args = ConfirmationRoute.Args( + icon = icon(Icons.Outlined.DeleteForever), + title = translate(Res.strings.sends_action_delete_confirmation_title), + ), + ), + ) { result -> + if (result is ConfirmationResult.Confirm) { + val sendIds = sends + .map { it.id } + .toSet() + removeSendById(sendIds) + .launchIn(appScope) + } + + val success = result is ConfirmationResult.Confirm + after?.invoke(success) + } + val intent = NavigationIntent.NavigateToRoute(route) + navigate(intent) + }, + ) + } + + private fun List.sorted() = this + .sortedWith(StringComparatorIgnoreCase { it.name }) + + private fun createPatchRequestSingle( + sends: List, + factory: () -> PatchSendRequest.Data, + ): PatchSendRequest = kotlin.run { + val data = factory() + val patch = sends + .associate { + it.id to data + } + PatchSendRequest( + patch = patch, + ) + } + + private fun createPatchRequestMultiple( + data: Map, + factory: (T) -> PatchSendRequest.Data, + ): PatchSendRequest = kotlin.run { + val patch = data + .mapValues { + factory(it.value) + } + PatchSendRequest( + patch = patch, + ) + } + + context(RememberStateFlowScope) + fun actions( + toolbox: SendToolbox, + sends: List, + canEdit: Boolean, + ) = kotlin.run { + buildContextItems { + section { + this += renameActionOrNull( + patchSendById = toolbox.patchSendById, + sends = sends, + canEdit = canEdit, + ) + this += renameFileActionOrNull( + patchSendById = toolbox.patchSendById, + sends = sends, + canEdit = canEdit, + ) + } + section { + this += changePasswordActionOrNull( + patchSendById = toolbox.patchSendById, + sends = sends, + canEdit = canEdit, + ) + this += removePasswordActionOrNull( + patchSendById = toolbox.patchSendById, + sends = sends, + canEdit = canEdit, + ) + } + section { + this += showEmailActionOrNull( + patchSendById = toolbox.patchSendById, + sends = sends, + canEdit = canEdit, + ) + this += hideEmailActionOrNull( + patchSendById = toolbox.patchSendById, + sends = sends, + canEdit = canEdit, + ) + } + section { + this += enableActionOrNull( + patchSendById = toolbox.patchSendById, + sends = sends, + canEdit = canEdit, + ) + this += disableActionOrNull( + patchSendById = toolbox.patchSendById, + sends = sends, + canEdit = canEdit, + ) + this += deleteActionOrNull( + removeSendById = toolbox.removeSendById, + sends = sends, + canDelete = canEdit, + ) + } + } + } + + // + // Selection + // + + fun canEditFlow( + profilesFlow: Flow>, + canWriteFlow: Flow, + ) = kotlin.run { + canWriteFlow + .flatMapLatest { canWrite -> + if (!canWrite) { + return@flatMapLatest flowOf(false) + } + + // Using send requires a user to have the + // Bitwarden premium. We don't want to show the + // new item button if a user won't be able to + // create an item anyway. + profilesFlow + .map { profiles -> + profiles.any { it.premium } + } + } + .distinctUntilChanged() + } + + context(RememberStateFlowScope) + fun selectionFlow( + selectionHandle: SelectionHandle, + sendsFlow: Flow>, + canEditFlow: Flow, + // + toolbox: SendToolbox, + ) = combine( + sendsFlow, + canEditFlow, + selectionHandle.idsFlow, + ) { sends, canEdit, selectedSendIds -> + if (selectedSendIds.isEmpty()) { + return@combine null + } + + val selectedSends = sends + .filter { it.id in selectedSendIds } + val actions = actions( + toolbox = toolbox, + sends = selectedSends, + canEdit = canEdit, + ) + Selection( + count = selectedSends.size, + actions = actions.toPersistentList(), + onClear = selectionHandle::clearSelection, + ) + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/view/SendViewState.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/view/SendViewState.kt index b18e784..b08fb0f 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/view/SendViewState.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/view/SendViewState.kt @@ -5,6 +5,7 @@ import arrow.optics.optics import com.artemchep.keyguard.common.model.DSend import com.artemchep.keyguard.feature.home.vault.model.VaultItemIcon import com.artemchep.keyguard.feature.home.vault.model.VaultViewItem +import com.artemchep.keyguard.ui.ContextItem import com.artemchep.keyguard.ui.FlatItemAction @Immutable @@ -27,7 +28,7 @@ data class SendViewState( val synced: Boolean, val onCopy: (() -> Unit)?, val onShare: (() -> Unit)?, - val actions: List, + val actions: List, val items: List, ) : Content { companion object; diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/view/SendViewStateProducer.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/view/SendViewStateProducer.kt index 6ddbe69..864024c 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/view/SendViewStateProducer.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/feature/send/view/SendViewStateProducer.kt @@ -57,10 +57,13 @@ import com.artemchep.keyguard.common.usecase.GetPasswordStrength import com.artemchep.keyguard.common.usecase.GetSends import com.artemchep.keyguard.common.usecase.GetWebsiteIcons import com.artemchep.keyguard.common.usecase.MoveCipherToFolderById +import com.artemchep.keyguard.common.usecase.PatchSendById import com.artemchep.keyguard.common.usecase.RemoveAttachment import com.artemchep.keyguard.common.usecase.RemoveCipherById +import com.artemchep.keyguard.common.usecase.RemoveSendById import com.artemchep.keyguard.common.usecase.RestoreCipherById import com.artemchep.keyguard.common.usecase.RetryCipher +import com.artemchep.keyguard.common.usecase.SendToolbox import com.artemchep.keyguard.common.usecase.TrashCipherById import com.artemchep.keyguard.common.usecase.WindowCoroutineScope import com.artemchep.keyguard.feature.attachments.model.AttachmentItem @@ -78,8 +81,10 @@ import com.artemchep.keyguard.feature.navigation.state.produceScreenState import com.artemchep.keyguard.feature.send.action.createSendActionOrNull import com.artemchep.keyguard.feature.send.action.createShareAction import com.artemchep.keyguard.feature.send.toVaultItemIcon +import com.artemchep.keyguard.feature.send.util.SendUtil 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.ChevronIcon import com.artemchep.keyguard.ui.icons.IconBox @@ -89,6 +94,7 @@ import com.artemchep.keyguard.ui.selection.selectionHandle import com.artemchep.keyguard.ui.text.annotate import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser import io.ktor.http.Url +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -120,18 +126,8 @@ fun sendViewScreenState( getAppIcons = instance(), getWebsiteIcons = instance(), getPasswordStrength = instance(), - cipherUnsecureUrlCheck = instance(), - cipherUnsecureUrlAutoFix = instance(), - cipherFieldSwitchToggle = instance(), - moveCipherToFolderById = instance(), - changeCipherNameById = instance(), - changeCipherPasswordById = instance(), retryCipher = instance(), - copyCipherById = instance(), - restoreCipherById = instance(), - trashCipherById = instance(), - removeCipherById = instance(), - favouriteCipherById = instance(), + toolbox = instance(), downloadManager = instance(), downloadAttachment = instance(), removeAttachment = instance(), @@ -173,18 +169,8 @@ fun sendViewScreenState( getAppIcons: GetAppIcons, getWebsiteIcons: GetWebsiteIcons, getPasswordStrength: GetPasswordStrength, - cipherUnsecureUrlCheck: CipherUnsecureUrlCheck, - cipherUnsecureUrlAutoFix: CipherUnsecureUrlAutoFix, - cipherFieldSwitchToggle: CipherFieldSwitchToggle, - moveCipherToFolderById: MoveCipherToFolderById, - changeCipherNameById: ChangeCipherNameById, - changeCipherPasswordById: ChangeCipherPasswordById, retryCipher: RetryCipher, - copyCipherById: CopyCipherById, - restoreCipherById: RestoreCipherById, - trashCipherById: TrashCipherById, - removeCipherById: RemoveCipherById, - favouriteCipherById: FavouriteCipherById, + toolbox: SendToolbox, downloadManager: DownloadManager, downloadAttachment: DownloadAttachment, removeAttachment: RemoveAttachment, @@ -241,6 +227,7 @@ fun sendViewScreenState( .firstOrNull { it.id == sendId && it.accountId == accountId } } .distinctUntilChanged() + launchAutoPopSelfHandler(secretFlow) combine( accountFlow, secretFlow, @@ -276,11 +263,17 @@ fun sendViewScreenState( } else { null } + + val actions = SendUtil.actions( + toolbox = toolbox, + sends = listOf(secretOrNull), + canEdit = canAddSecret, + ) SendViewState.Content.Cipher( data = secretOrNull, icon = icon, synced = true, - actions = emptyList(), + actions = actions, onCopy = if (info != null) { // lambda { @@ -393,8 +386,7 @@ private fun RememberStateFlowScope.oh( emit(section) emit(url) - val password = send.password - if (password != null) { + if (send.hasPassword) { val w = VaultViewItem.Label( id = "url.password", text = AnnotatedString( diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/api/SyncEngine.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/api/SyncEngine.kt index f59ab4f..793c293 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/api/SyncEngine.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/api/SyncEngine.kt @@ -12,6 +12,7 @@ import com.artemchep.keyguard.core.store.DatabaseSyncer import com.artemchep.keyguard.core.store.bitwarden.BitwardenCipher import com.artemchep.keyguard.core.store.bitwarden.BitwardenCollection import com.artemchep.keyguard.core.store.bitwarden.BitwardenFolder +import com.artemchep.keyguard.core.store.bitwarden.BitwardenOptionalStringNullable import com.artemchep.keyguard.core.store.bitwarden.BitwardenOrganization import com.artemchep.keyguard.core.store.bitwarden.BitwardenProfile import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend @@ -25,6 +26,7 @@ import com.artemchep.keyguard.provider.bitwarden.api.builder.delete import com.artemchep.keyguard.provider.bitwarden.api.builder.get import com.artemchep.keyguard.provider.bitwarden.api.builder.post import com.artemchep.keyguard.provider.bitwarden.api.builder.put +import com.artemchep.keyguard.provider.bitwarden.api.builder.removePassword import com.artemchep.keyguard.provider.bitwarden.api.builder.restore import com.artemchep.keyguard.provider.bitwarden.api.builder.sync import com.artemchep.keyguard.provider.bitwarden.api.builder.trash @@ -33,11 +35,13 @@ import com.artemchep.keyguard.provider.bitwarden.crypto.BitwardenCr import com.artemchep.keyguard.provider.bitwarden.crypto.BitwardenCrCta import com.artemchep.keyguard.provider.bitwarden.crypto.BitwardenCrImpl import com.artemchep.keyguard.provider.bitwarden.crypto.BitwardenCrKey +import com.artemchep.keyguard.provider.bitwarden.crypto.CryptoKey import com.artemchep.keyguard.provider.bitwarden.crypto.appendOrganizationToken import com.artemchep.keyguard.provider.bitwarden.crypto.appendProfileToken -import com.artemchep.keyguard.provider.bitwarden.crypto.appendSendToken import com.artemchep.keyguard.provider.bitwarden.crypto.appendUserToken +import com.artemchep.keyguard.provider.bitwarden.crypto.decodeSymmetricOrThrow import com.artemchep.keyguard.provider.bitwarden.crypto.encrypted +import com.artemchep.keyguard.provider.bitwarden.crypto.makeSendCryptoKey import com.artemchep.keyguard.provider.bitwarden.crypto.transform import com.artemchep.keyguard.provider.bitwarden.entity.CipherEntity import com.artemchep.keyguard.provider.bitwarden.entity.CollectionEntity @@ -47,6 +51,7 @@ import com.artemchep.keyguard.provider.bitwarden.entity.SyncProfile import com.artemchep.keyguard.provider.bitwarden.entity.SyncSends import com.artemchep.keyguard.provider.bitwarden.entity.request.CipherUpdate import com.artemchep.keyguard.provider.bitwarden.entity.request.FolderUpdate +import com.artemchep.keyguard.provider.bitwarden.entity.request.SendUpdate import com.artemchep.keyguard.provider.bitwarden.entity.request.of import com.artemchep.keyguard.provider.bitwarden.sync.SyncManager import io.ktor.client.HttpClient @@ -122,7 +127,6 @@ class SyncEngine( val now = Clock.System.now() val crypto = crypto( profile = response.profile, - sends = response.sends.orEmpty(), ) fun getCodec( @@ -135,7 +139,7 @@ class SyncEngine( val key = BitwardenCrKey.SendToken(sendId) BitwardenCrCta.BitwardenCrCtaEnv( key = key, - encryptionType = CipherEncryptor.Type.AesCbc256_B64, + encryptionType = envEncryptionType, ) } else if (organizationId != null) { val key = BitwardenCrKey.OrganizationToken(organizationId) @@ -156,6 +160,43 @@ class SyncEngine( ) } + fun getCodecPair( + mode: BitwardenCrCta.Mode, + key: ByteArray, + ) = kotlin.run { + val itemCrypto = kotlin.run { + val symmetricCryptoKey = key + .let(cryptoGenerator::makeSendCryptoKey) + .let(CryptoKey.Companion::decodeSymmetricOrThrow) + val cryptoKey = BitwardenCrKey.CryptoKey( + symmetricCryptoKey = symmetricCryptoKey, + ) + val cryptoKeyEnv = BitwardenCrCta.BitwardenCrCtaEnv( + key = cryptoKey, + ) + crypto.cta( + env = cryptoKeyEnv, + mode = mode, + ) + } + val globalCrypto = getCodec( + mode = mode, + ) + itemCrypto to globalCrypto + } + + fun getCodecPairFromEncrypted( + mode: BitwardenCrCta.Mode, + keyCipherText: String, + ) = kotlin.run { + val key = crypto.decoder(BitwardenCrKey.UserToken)(keyCipherText) + .data + getCodecPair( + key = key, + mode = mode, + ) + } + // // Profile // @@ -940,8 +981,28 @@ class SyncEngine( localReEncoder = { model -> model }, - localDecoder = { l, d -> - Unit + localDecoder = { local, remote -> + val itemKey = requireNotNull(local.keyBase64) + .let(base64Service::decode) + val ( + itemCrypto, + globalCrypto, + ) = getCodecPair( + mode = BitwardenCrCta.Mode.ENCRYPT, + key = itemKey, + ) + val encryptedSend = local.transform( + itemCrypto = itemCrypto, + globalCrypto = globalCrypto, + ) + with(cryptoGenerator) { + with(base64Service) { + SendUpdate.of( + model = encryptedSend, + key = itemKey, + ) + } + } to local }, localDeleteById = { ids -> sendDao.transaction { @@ -966,32 +1027,36 @@ class SyncEngine( remoteItems = response.sends.orEmpty(), remoteLens = SyncManager.Lens( getId = { it.id }, - getRevisionDate = { Instant.DISTANT_FUTURE }, + getRevisionDate = { it.revisionDate }, ), remoteDecoder = { remote, local -> - val codec = getCodec( + val ( + itemCrypto, + globalCrypto, + ) = getCodecPairFromEncrypted( mode = BitwardenCrCta.Mode.DECRYPT, - sendId = remote.id, + keyCipherText = remote.key, ) - val codec2 = getCodec( - mode = BitwardenCrCta.Mode.DECRYPT, - ) - codec + itemCrypto .sendDecoder( entity = remote, - codec2 = codec2, + codec2 = globalCrypto, localSyncId = local?.sendId, ) }, remoteDeleteById = { id -> - TODO() + user.env.back().api.sends.focus(id).delete( + httpClient = httpClient, + env = env, + token = user.token.accessToken, + ) }, remoteDecodedFallback = { remote, localOrNull, e -> e.printStackTrace() val service = BitwardenService( remote = BitwardenService.Remote( id = remote.id, - revisionDate = now, + revisionDate = remote.revisionDate, deletedDate = null, ), error = BitwardenService.Error( @@ -1015,8 +1080,68 @@ class SyncEngine( ) model }, - remotePut = { - TODO() + remotePut = { (r, local) -> + val sendsApi = user.env.back().api.sends + val sendResponse = when (r) { + is SendUpdate.Modify -> { + val cipherApi = sendsApi.focus(r.sendId) + var cipherRequest = r.sendRequest + + val hasChanged = + r.source.service.remote?.revisionDate != r.source.revisionDate + if (hasChanged) { + val putCipher = cipherApi.put( + httpClient = httpClient, + env = env, + token = user.token.accessToken, + body = cipherRequest, + ) + + // Removing a password has a separate endpoint and + // requires an additional request. + val shouldRemovePassword = r.source.changes?.passwordBase64 + ?.let { it is BitwardenOptionalStringNullable.Some && it.value == null } == true + if (shouldRemovePassword && putCipher.password != null) { + cipherApi.removePassword( + httpClient = httpClient, + env = env, + token = user.token.accessToken, + ) + } + } + + cipherApi.get( + httpClient = httpClient, + env = env, + token = user.token.accessToken, + ) + } + + is SendUpdate.Create -> { + sendsApi.post( + httpClient = httpClient, + env = env, + token = user.token.accessToken, + body = r.sendRequest, + ) + } + } + + val itemKey = requireNotNull(local.keyBase64) + .let(base64Service::decode) + val ( + itemCrypto, + globalCrypto, + ) = getCodecPair( + mode = BitwardenCrCta.Mode.DECRYPT, + key = itemKey, + ) + itemCrypto + .sendDecoder( + entity = sendResponse, + codec2 = globalCrypto, + localSyncId = local.sendId, + ) }, onLog = { msg, logLevel -> logRepository.post(TAG, msg, logLevel) @@ -1030,7 +1155,6 @@ class SyncEngine( private fun crypto( profile: SyncProfile, - sends: List, ): BitwardenCr = kotlin.run { val builder = BitwardenCrImpl( cipherEncryptor = cipherEncryptor, @@ -1054,13 +1178,6 @@ class SyncEngine( keyCipherText = organization.key, ) } - - sends.forEach { send -> - appendSendToken( - id = send.id, - keyCipherText = send.key, - ) - } } builder.build() } diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/api/builder/ServerEnvApi.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/api/builder/ServerEnvApi.kt index 91e3f4d..dc8b8a7 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/api/builder/ServerEnvApi.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/api/builder/ServerEnvApi.kt @@ -455,12 +455,37 @@ suspend fun ServerEnvApi.Sends.post( route = "post-send", ) +suspend fun ServerEnvApi.Sends.Send.get( + httpClient: HttpClient, + env: ServerEnv, + token: String, +) = httpClient + .get(url) { + headers(env) + header("Authorization", "Bearer $token") + attributes.put(routeAttribute, "get-send") + } + .bodyOrApiException() + +suspend fun ServerEnvApi.Sends.Send.put( + httpClient: HttpClient, + env: ServerEnv, + token: String, + body: SendRequest, +) = url.put( + httpClient = httpClient, + env = env, + token = token, + body = body, + route = "put-send", +) + suspend fun ServerEnvApi.Sends.Send.removePassword( httpClient: HttpClient, env: ServerEnv, token: String, ) = httpClient - .put(url) { + .put(removePassword) { headers(env) header("Authorization", "Bearer $token") attributes.put(routeAttribute, "remove-password-send") diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/crypto/BitwardenCrypto.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/crypto/BitwardenCrypto.kt index d0484c2..ae82f07 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/crypto/BitwardenCrypto.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/crypto/BitwardenCrypto.kt @@ -27,6 +27,11 @@ sealed interface BitwardenCrKey { data class SendToken( val id: String, ) : BitwardenCrKey + + data class CryptoKey( + val symmetricCryptoKey: SymmetricCryptoKey2? = null, + val asymmetricCryptoKey: AsymmetricCryptoKey? = null, + ) : BitwardenCrKey } interface BitwardenCr { @@ -54,7 +59,7 @@ class BitwardenCrCta( data class BitwardenCrCtaEnv( val key: BitwardenCrKey, /** An encryption type */ - val encryptionType: CipherEncryptor.Type, + val encryptionType: CipherEncryptor.Type = CipherEncryptor.Type.AesCbc256_HmacSha256_B64, ) fun withEnv(env: BitwardenCrCtaEnv) = BitwardenCrCta( @@ -164,9 +169,27 @@ class BitwardenCrImpl( // Decoder // - override fun decoder(key: BitwardenCrKey): Decoder = decoders.getValue(key) + override fun decoder(key: BitwardenCrKey): Decoder = when (key) { + is BitwardenCrKey.CryptoKey -> { + cipherEncryptor::decode2 + .partially2(key.symmetricCryptoKey) + .partially2(key.asymmetricCryptoKey) + .withExceptionHandling( + key, + symmetricCryptoKey = key.symmetricCryptoKey, + ) + } + else -> decoders.getValue(key) + } - override fun encoder(key: BitwardenCrKey): Encoder = encoders.getValue(key) + override fun encoder(key: BitwardenCrKey): Encoder = when (key) { + is BitwardenCrKey.CryptoKey -> { + cipherEncryptor::encode2 + .partially3(key.symmetricCryptoKey) + .partially3(key.asymmetricCryptoKey) + } + else -> encoders.getValue(key) + } override fun cta( env: BitwardenCrCta.BitwardenCrCtaEnv, @@ -299,37 +322,6 @@ fun BitwardenCrFactoryScope.appendOrganizationToken( appendEncoder(key, encoder) } -fun BitwardenCrFactoryScope.appendSendToken( - id: String, - keyCipherText: String, -) { - val symmetricCryptoKey = decoder(BitwardenCrKey.UserToken)(keyCipherText) - .data - .let { keyMaterial -> - val key = cryptoGenerator.hkdf( - seed = keyMaterial, - salt = "bitwarden-send".toByteArray(), - info = "send".toByteArray(), - length = 64, - ) - key - } - .let(CryptoKey::decodeSymmetricOrThrow) - val key = BitwardenCrKey.SendToken(id) - val decoder = cipherEncryptor::decode2 - .partially2(symmetricCryptoKey) - .partially2(null) - .withExceptionHandling( - key, - symmetricCryptoKey = symmetricCryptoKey, - ) - val encoder = cipherEncryptor::encode2 - .partially3(symmetricCryptoKey) - .partially3(null) - appendDecoder(key, decoder) - appendEncoder(key, encoder) -} - fun BitwardenCrFactoryScope.appendOrganizationToken2( id: String, keyData: ByteArray, @@ -349,3 +341,20 @@ fun BitwardenCrFactoryScope.appendOrganizationToken2( appendDecoder(key, decoder) appendEncoder(key, encoder) } + +// +// Sends +// + +fun CryptoGenerator.makeSendCryptoKeyMaterial() = seed(length = 16) + +fun CryptoGenerator.makeSendCryptoKey( + keyMaterial: ByteArray = makeSendCryptoKeyMaterial(), +): ByteArray { + return hkdf( + seed = keyMaterial, + salt = "bitwarden-send".toByteArray(), + info = "send".toByteArray(), + length = 64, + ) +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/crypto/CipherCrypto.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/crypto/CipherCrypto.kt index 559e280..160b476 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/crypto/CipherCrypto.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/crypto/CipherCrypto.kt @@ -82,7 +82,7 @@ fun List.transform( fun BitwardenCipher.Login.Uri.transform( crypto: BitwardenCrCta, ) = copy( - uri = crypto.transformString(uri), + uri = crypto.transformString(uri.orEmpty()), ) @JvmName("encryptListOfBitwardenCipherLoginFido2Credentials") diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/crypto/SendCrypto.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/crypto/SendCrypto.kt index d8fe48a..d625800 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/crypto/SendCrypto.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/crypto/SendCrypto.kt @@ -3,17 +3,17 @@ package com.artemchep.keyguard.provider.bitwarden.crypto import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend fun BitwardenSend.transform( - crypto: BitwardenCrCta, - codec2: BitwardenCrCta, + itemCrypto: BitwardenCrCta, + globalCrypto: BitwardenCrCta, ) = copy( // common // key is encoded with profile key - keyBase64 = keyBase64?.let(codec2::transformBase64), - name = crypto.transformString(name), - notes = crypto.transformString(notes), + keyBase64 = keyBase64?.let(globalCrypto::transformBase64), + name = itemCrypto.transformString(name), + notes = itemCrypto.transformString(notes), // types - text = text?.transform(crypto), - file = file?.transform(crypto), + text = text?.transform(itemCrypto), + file = file?.transform(itemCrypto), ) fun BitwardenSend.File.transform( @@ -26,5 +26,5 @@ fun BitwardenSend.File.transform( fun BitwardenSend.Text.transform( crypto: BitwardenCrCta, ) = copy( - text = crypto.transformString(text), + text = crypto.transformString(requireNotNull(text)), ) diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/entity/api/LoginUriRequest.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/entity/api/LoginUriRequest.kt index 4176bb7..c99b40f 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/entity/api/LoginUriRequest.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/entity/api/LoginUriRequest.kt @@ -20,7 +20,7 @@ fun LoginUriRequest.Companion.of( model: BitwardenCipher.Login.Uri, ) = kotlin.run { LoginUriRequest( - uri = model.uri.orEmpty(), + uri = requireNotNull(model.uri) { "Login URI request must have a non-null URI!" }, match = model.match?.let(UriMatchTypeEntity::of), ) } diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/entity/request/SendRequest.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/entity/request/SendRequest.kt index 523ad41..db4df9b 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/entity/request/SendRequest.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/entity/request/SendRequest.kt @@ -1,12 +1,20 @@ package com.artemchep.keyguard.provider.bitwarden.entity.request +import com.artemchep.keyguard.common.service.crypto.CryptoGenerator +import com.artemchep.keyguard.common.service.text.Base64Service +import com.artemchep.keyguard.core.store.bitwarden.BitwardenOptionalStringNullable +import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend import com.artemchep.keyguard.provider.bitwarden.entity.SendTypeEntity +import com.artemchep.keyguard.provider.bitwarden.entity.of +import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class SendRequest( + @SerialName("key") + val key: String, @SerialName("type") val type: SendTypeEntity, @SerialName("name") @@ -33,27 +41,92 @@ data class SendRequest( val text: SendTextRequest?, @SerialName("file") val file: SendFileRequest?, -) +) { + companion object +} @Serializable data class SendTextRequest( - @SerialName("name") - val name: String, - @SerialName("notes") - val notes: String, - @SerialName("password") - val password: String? = null, - val type: SendTypeEntity, -) - + @SerialName("text") + val text: String, + @SerialName("hidden") + val hidden: Boolean, +) { + companion object +} @Serializable data class SendFileRequest( - @SerialName("name") - val name: String, - @SerialName("notes") - val notes: String, - @SerialName("password") - val password: String? = null, - val type: SendTypeEntity, -) + @SerialName("fileName") + val fileName: String? = null, +) { + companion object +} + +context(CryptoGenerator, Base64Service) +fun SendRequest.Companion.of( + model: BitwardenSend, + key: ByteArray, +) = kotlin.run { + val type = SendTypeEntity.of(model.type) + val text = model.text + ?.let(SendTextRequest::of) + val file = model.file + ?.let(SendFileRequest::of) + val deletionDate = model.deletedDate + ?: Clock.System.now() + + val passwordHashBase64 = when (val pwd = model.changes?.passwordBase64) { + is BitwardenOptionalStringNullable.Some -> run { + pwd.value + // Bitwarden doesn't allow us to remove the password + // within the PUT request. + ?: return@run null + val password = decode(pwd.value) + // Send the hash code of that password, instead of + // sending the actual password. + val passwordHash = pbkdf2( + seed = password, + salt = key, + iterations = 100_000, + ) + encodeToString(passwordHash) + } + + is BitwardenOptionalStringNullable.None, + null, + -> null + } + val keyBase64 = requireNotNull(model.keyBase64) { "Send request must have a cipher key!" } + SendRequest( + type = type, + key = keyBase64, + name = requireNotNull(model.name) { "Send request must have a non-null name!" }, + notes = model.notes, + password = passwordHashBase64, + disabled = model.disabled, + hideEmail = model.hideEmail == true, + deletionDate = deletionDate, + expirationDate = model.expirationDate, + maxAccessCount = model.maxAccessCount, + text = text, + file = file, + ) +} + +fun SendTextRequest.Companion.of( + model: BitwardenSend.Text, +) = kotlin.run { + SendTextRequest( + text = model.text.orEmpty(), + hidden = model.hidden == true, + ) +} + +fun SendFileRequest.Companion.of( + model: BitwardenSend.File, +) = kotlin.run { + SendFileRequest( + fileName = model.fileName, + ) +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/entity/request/SendUpdate.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/entity/request/SendUpdate.kt new file mode 100644 index 0000000..aeb87ff --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/entity/request/SendUpdate.kt @@ -0,0 +1,49 @@ +package com.artemchep.keyguard.provider.bitwarden.entity.request + +import com.artemchep.keyguard.common.service.crypto.CryptoGenerator +import com.artemchep.keyguard.common.service.text.Base64Service +import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend + +sealed interface SendUpdate { + companion object; + + val source: BitwardenSend + + data class Modify( + override val source: BitwardenSend, + val sendId: String, + val sendRequest: SendRequest, + ) : SendUpdate + + data class Create( + override val source: BitwardenSend, + val sendRequest: SendRequest, + ) : SendUpdate +} + +context(CryptoGenerator, Base64Service) +fun SendUpdate.Companion.of( + model: BitwardenSend, + key: ByteArray, +) = when { + model.service.remote != null -> { + SendUpdate.Modify( + source = model, + sendId = model.service.remote.id, + sendRequest = SendRequest.of( + model = model, + key = key, + ), + ) + } + + else -> { + SendUpdate.Create( + source = model, + sendRequest = SendRequest.of( + model = model, + key = key, + ), + ) + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/mapper/SendMapping.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/mapper/SendMapping.kt index f173192..3acedbb 100644 --- a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/mapper/SendMapping.kt +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/mapper/SendMapping.kt @@ -1,6 +1,7 @@ package com.artemchep.keyguard.provider.bitwarden.mapper import com.artemchep.keyguard.common.model.DSend +import com.artemchep.keyguard.core.store.bitwarden.BitwardenOptionalStringNullable import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend suspend fun BitwardenSend.toDomain( @@ -25,9 +26,14 @@ suspend fun BitwardenSend.toDomain( notes = notes.orEmpty(), accessCount = accessCount, maxAccessCount = maxAccessCount, + hasPassword = changes?.passwordBase64 + ?.let { it as? BitwardenOptionalStringNullable.Some } + ?.let { it.value != null } + ?: (password != null), + synced = !service.deleted && + revisionDate == service.remote?.revisionDate, disabled = disabled, hideEmail = hideEmail ?: false, - password = password, // types type = type, text = text?.toDomain(), diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/AddSend.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/AddSend.kt new file mode 100644 index 0000000..a8b81b5 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/AddSend.kt @@ -0,0 +1,209 @@ +package com.artemchep.keyguard.provider.bitwarden.usecase + +import com.artemchep.keyguard.common.io.IO +import com.artemchep.keyguard.common.io.attempt +import com.artemchep.keyguard.common.io.bind +import com.artemchep.keyguard.common.io.combine +import com.artemchep.keyguard.common.io.effectMap +import com.artemchep.keyguard.common.io.flatMap +import com.artemchep.keyguard.common.io.flatTap +import com.artemchep.keyguard.common.io.io +import com.artemchep.keyguard.common.io.ioEffect +import com.artemchep.keyguard.common.io.ioUnit +import com.artemchep.keyguard.common.io.map +import com.artemchep.keyguard.common.model.AccountId +import com.artemchep.keyguard.common.model.DSecret +import com.artemchep.keyguard.common.model.DSend +import com.artemchep.keyguard.common.model.canDelete +import com.artemchep.keyguard.common.model.create.CreateRequest +import com.artemchep.keyguard.common.model.create.CreateSendRequest +import com.artemchep.keyguard.common.service.crypto.CryptoGenerator +import com.artemchep.keyguard.common.service.text.Base64Service +import com.artemchep.keyguard.common.usecase.AddFolder +import com.artemchep.keyguard.common.usecase.AddSend +import com.artemchep.keyguard.common.usecase.GetPasswordStrength +import com.artemchep.keyguard.common.usecase.TrashCipherById +import com.artemchep.keyguard.core.store.bitwarden.BitwardenCipher +import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend +import com.artemchep.keyguard.core.store.bitwarden.BitwardenService +import com.artemchep.keyguard.provider.bitwarden.crypto.makeSendCryptoKey +import com.artemchep.keyguard.provider.bitwarden.crypto.makeSendCryptoKeyMaterial +import com.artemchep.keyguard.provider.bitwarden.usecase.util.ModifyDatabase +import kotlinx.collections.immutable.toPersistentList +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import org.kodein.di.DirectDI +import org.kodein.di.instance +import kotlin.time.Duration + +/** + * @author Artem Chepurnyi + */ +class AddSendImpl( + private val modifyDatabase: ModifyDatabase, + private val cryptoGenerator: CryptoGenerator, + private val base64Service: Base64Service, +) : AddSend { + companion object { + private const val TAG = "AddSend.bitwarden" + } + + constructor(directDI: DirectDI) : this( + modifyDatabase = directDI.instance(), + cryptoGenerator = directDI.instance(), + base64Service = directDI.instance(), + ) + + override fun invoke( + sendIdsToRequests: Map, + ): IO> = ioEffect { + sendIdsToRequests + }.flatMap { map -> + modifyDatabase { database -> + val dao = database.sendQueries + val now = Clock.System.now() + + val oldSendsMap = map + .keys + .filterNotNull() + .mapNotNull { sendId -> + dao + .getBySendId(sendId) + .executeAsOneOrNull() + } + .associateBy { it.sendId } + + val models = map + .map { (sendId, request) -> + val old = oldSendsMap[sendId]?.data_ + BitwardenSend.of( + cryptoGenerator = cryptoGenerator, + base64Service = base64Service, + now = now, + request = request, + old = old, + ) + } + if (models.isEmpty()) { + return@modifyDatabase ModifyDatabase.Result( + changedAccountIds = emptySet(), + value = emptyList(), + ) + } + dao.transaction { + models.forEach { send -> + dao.insert( + sendId = send.sendId, + accountId = send.accountId, + data = send, + ) + } + } + + val changedAccountIds = models + .map { AccountId(it.accountId) } + .toSet() + ModifyDatabase.Result( + changedAccountIds = changedAccountIds, + value = models + .map { it.sendId }, + ) + } + } +} + +private suspend fun BitwardenSend.Companion.of( + cryptoGenerator: CryptoGenerator, + base64Service: Base64Service, + request: CreateSendRequest, + now: Instant, + old: BitwardenSend? = null, +): BitwardenSend { + val accountId = request.ownership?.accountId + require(old?.service?.deleted != true) { + "Can not modify deleted send!" + } + requireNotNull(accountId) { "Send must have an account!" } + + val type = when (request.type) { + DSend.Type.Text -> BitwardenSend.Type.Text + DSend.Type.File -> BitwardenSend.Type.File + null, + DSend.Type.None, + -> error("Send must have a type!") + } + + var text: BitwardenSend.Text? = null + var file: BitwardenSend.File? = null + when (type) { + BitwardenSend.Type.Text -> { + text = BitwardenSend.Text.of( + request = request, + old = old, + ) + } + + BitwardenSend.Type.File -> { + file = BitwardenSend.File.of( + request = request, + ) + } + + BitwardenSend.Type.None -> { + error("Send must have a type!") + } + } + + val keyBase64 = old?.keyBase64 + ?: run { + val key = cryptoGenerator.makeSendCryptoKeyMaterial() + base64Service.encodeToString(key) + } + val cipherId = old?.sendId + ?: cryptoGenerator.uuid() + val createdDate = old?.createdDate ?: request.now + val deletedDate = old?.deletedDate ?: request.now.plus(with(Duration) {3L.days}) + return BitwardenSend( + accountId = accountId, + sendId = cipherId, + accessId = "", + revisionDate = now, + createdDate = createdDate, + deletedDate = deletedDate, + // service fields + service = BitwardenService( + remote = old?.service?.remote, + deleted = false, + version = BitwardenService.VERSION, + ), + // common + keyBase64 = keyBase64, + name = request.title, + notes = request.note, + accessCount = 0, + maxAccessCount = null, + password = null, + disabled = false, + hideEmail = null, + // types + type = type, + text = text, + file = file, + ) +} + +private suspend fun BitwardenSend.Text.Companion.of( + request: CreateSendRequest, + old: BitwardenSend? = null, +): BitwardenSend.Text { + return BitwardenSend.Text( + text = request.text.text, + hidden = request.text.hidden, + ) +} + +private suspend fun BitwardenSend.File.Companion.of( + request: CreateSendRequest, +): BitwardenSend.File { + TODO() +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/PatchSendById.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/PatchSendById.kt new file mode 100644 index 0000000..89894ff --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/PatchSendById.kt @@ -0,0 +1,70 @@ +package com.artemchep.keyguard.provider.bitwarden.usecase + +import arrow.core.getOrElse +import com.artemchep.keyguard.common.io.map +import com.artemchep.keyguard.common.model.PatchSendRequest +import com.artemchep.keyguard.common.service.text.Base64Service +import com.artemchep.keyguard.common.usecase.PatchSendById +import com.artemchep.keyguard.core.store.bitwarden.BitwardenOptionalStringNullable +import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend +import com.artemchep.keyguard.provider.bitwarden.usecase.util.ModifySendById +import org.kodein.di.DirectDI +import org.kodein.di.instance + +/** + * @author Artem Chepurnyi + */ +class PatchSendByIdImpl( + private val modifySendById: ModifySendById, + private val base64Service: Base64Service, +) : PatchSendById { + companion object { + private const val TAG = "PatchSendById.bitwarden" + } + + constructor(directDI: DirectDI) : this( + modifySendById = directDI.instance(), + base64Service = directDI.instance(), + ) + + override fun invoke( + patch: PatchSendRequest, + ) = performPatchSend( + patch = patch, + ).map { true } + + private fun performPatchSend( + patch: PatchSendRequest, + ) = modifySendById( + sendIds = patch.patch.keys, + ) { model -> + val p = patch.patch.getValue(model.sendId) + + val data = model.data_ + .run { + val passwordBase64 = p.password + .map { newPassword -> + val newPasswordBase64 = newPassword + ?.let(base64Service::encodeToString) + newPasswordBase64.let(BitwardenOptionalStringNullable::Some) + } + .getOrElse { BitwardenOptionalStringNullable.None } + copy( + name = p.name.getOrElse { name }, + hideEmail = p.hideEmail.getOrElse { hideEmail }, + disabled = p.disabled.getOrElse { disabled }, + file = p.fileName + .map { fileName -> + file?.copy( + fileName = fileName, + ) + } + .getOrElse { file }, + changes = (changes ?: BitwardenSend.Changes()).copy( + passwordBase64 = passwordBase64, + ), + ) + } + model.copy(data_ = data) + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/RemoveSendById.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/RemoveSendById.kt new file mode 100644 index 0000000..af46d57 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/RemoveSendById.kt @@ -0,0 +1,45 @@ +package com.artemchep.keyguard.provider.bitwarden.usecase + +import com.artemchep.keyguard.common.io.IO +import com.artemchep.keyguard.common.io.map +import com.artemchep.keyguard.common.usecase.RemoveSendById +import com.artemchep.keyguard.core.store.bitwarden.BitwardenSend +import com.artemchep.keyguard.core.store.bitwarden.deleted +import com.artemchep.keyguard.core.store.bitwarden.service +import com.artemchep.keyguard.provider.bitwarden.usecase.util.ModifySendById +import org.kodein.di.DirectDI +import org.kodein.di.instance + +/** + * @author Artem Chepurnyi + */ +class RemoveSendByIdImpl( + private val modifySendById: ModifySendById, +) : RemoveSendById { + companion object { + private const val TAG = "RemoveSendById.bitwarden" + } + + constructor(directDI: DirectDI) : this( + modifySendById = directDI.instance(), + ) + + override fun invoke( + sendIds: Set, + ): IO = performRemoveSend( + sendIds = sendIds, + ).map { Unit } + + private fun performRemoveSend( + sendIds: Set, + ) = modifySendById( + sendIds = sendIds, + checkIfStub = false, // we want to be able to delete failed items + ) { model -> + var new = model + new = new.copy( + data_ = BitwardenSend.service.deleted.set(new.data_, true), + ) + new + } +} diff --git a/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/util/ModifySendById.kt b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/util/ModifySendById.kt new file mode 100644 index 0000000..7e511e5 --- /dev/null +++ b/common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/util/ModifySendById.kt @@ -0,0 +1,90 @@ +package com.artemchep.keyguard.provider.bitwarden.usecase.util + +import com.artemchep.keyguard.common.io.IO +import com.artemchep.keyguard.common.model.AccountId +import com.artemchep.keyguard.data.bitwarden.Send +import kotlinx.datetime.Clock +import org.kodein.di.DirectDI +import org.kodein.di.instance + +/** + * @author Artem Chepurnyi + */ +class ModifySendById( + private val modifyDatabase: ModifyDatabase, +) { + companion object { + private const val TAG = "ModifySendById.bitwarden" + } + + constructor(directDI: DirectDI) : this( + modifyDatabase = directDI.instance(), + ) + + operator fun invoke( + sendIds: Set, + checkIfStub: Boolean = true, + checkIfChanged: Boolean = true, + updateRevisionDate: Boolean = true, + transform: suspend (Send) -> Send, + ): IO> = modifyDatabase { database -> + val dao = database.sendQueries + val now = Clock.System.now() + val models = dao + .get() + .executeAsList() + .filter { + it.sendId in sendIds + } + .mapNotNull { model -> + // If the send was not properly decoded, then + // prevent it from being pushed to backend. + val service = model.data_.service + if (checkIfStub && !service.canEdit()) { + return@mapNotNull null + } + var new = model + new = transform(new) + // If the cipher was not changed, then we do not need to + // update it in the database. + if (checkIfChanged && new == model) { + return@mapNotNull null + } + + if (updateRevisionDate) { + new = new.copy( + data_ = new.data_.copy( + revisionDate = now, + ), + ) + } + new + } + if (models.isEmpty()) { + return@modifyDatabase ModifyDatabase.Result( + changedAccountIds = emptySet(), + value = emptySet(), + ) + } + dao.transaction { + models.forEach { send -> + dao.insert( + sendId = send.sendId, + accountId = send.accountId, + data = send.data_, + ) + } + } + + val changedAccountIds = models + .map { AccountId(it.accountId) } + .toSet() + val changedSendIds = models + .map { it.sendId } + .toSet() + ModifyDatabase.Result( + changedAccountIds = changedAccountIds, + value = changedSendIds, + ) + } +} diff --git a/common/src/commonMain/resources/MR/base/strings.xml b/common/src/commonMain/resources/MR/base/strings.xml index 2ea9e28..ebb882a 100644 --- a/common/src/commonMain/resources/MR/base/strings.xml +++ b/common/src/commonMain/resources/MR/base/strings.xml @@ -58,6 +58,7 @@ Postal code Pull to search Save + Save to URI URIs Note @@ -248,9 +249,35 @@ Trash Restore Delete forever + Delete forever? Configure Watchtower alerts Move associated items to trash + Change name + Change names + Change file name + Change file names + Set password + Set passwords + Require a password for users to access this item + Change password + Change passwords + Remove password + Remove passwords + Remove password? + Remove passwords? + Do not require a password for users to access this item + Hide email + Show email + Activate + Active send is accessible by a public URL + Activate? + Deactivate + Inactive send is inaccessible by a public URL + Deactivate? + Delete forever + Delete forever? + Open with… Send with… Open in a file manager @@ -577,6 +604,9 @@ Auth re-prompt Ask to authenticate again when you view or autofill a cipher + New item + Edit item + Add Bitwarden account We are not affiliated, associated, authorized, endorsed by, or in any way officially connected with the Bitwarden, Inc., or any of its subsidiaries or its affiliates.