Skip to content

Commit 66ea746

Browse files
committed
improvement: During merge of items provide an option to keep/archive/trash origin items #1376
1 parent 8ffa2f3 commit 66ea746

8 files changed

Lines changed: 170 additions & 49 deletions

File tree

common/src/commonMain/composeResources/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,8 @@
807807
<string name="additem_header_edit_title">Edit item</string>
808808
<string name="additem_header_merge_title">Merge items</string>
809809
<string name="additem_merge_remove_origin_ciphers_title">Move origin items to trash</string>
810+
<string name="additem_merge_archive_origin_ciphers_title">Move origin items to archive</string>
811+
<string name="additem_merge_keep_origin_ciphers_title">Keep origin items</string>
810812
<string name="additem_merge_attachments_note">Merging items does not merge their attachments! The created item will not have any attachments added.</string>
811813
<string name="additem_note_placeholder">Add any notes about this item here</string>
812814
<string name="additem_markdown_note">Style text with <xliff:g id="italic" example="italic">%1$s</xliff:g> or <xliff:g id="bold" example="bold">%2$s</xliff:g> and more. Limited Markdown syntax is supported.</string>

common/src/commonMain/kotlin/com/artemchep/keyguard/common/model/create/CreateRequest.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,14 @@ data class CreateRequest(
5656
@optics
5757
data class Merge(
5858
val ciphers: List<DSecret>,
59-
val removeOrigin: Boolean,
59+
val postAction: PostAction?,
6060
) {
6161
companion object;
62+
63+
enum class PostAction {
64+
ARCHIVE,
65+
TRASH,
66+
}
6267
}
6368

6469
@optics

common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/AddScreen.kt

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,28 @@ package com.artemchep.keyguard.feature.home.vault.add
55
import androidx.compose.foundation.layout.Arrangement
66
import androidx.compose.foundation.layout.Column
77
import androidx.compose.foundation.layout.Row
8+
import androidx.compose.foundation.layout.RowScope
89
import androidx.compose.foundation.layout.Spacer
910
import androidx.compose.foundation.layout.fillMaxWidth
1011
import androidx.compose.foundation.layout.height
1112
import androidx.compose.foundation.layout.padding
1213
import androidx.compose.foundation.layout.width
1314
import androidx.compose.foundation.lazy.LazyListScope
1415
import androidx.compose.material.icons.Icons
16+
import androidx.compose.material.icons.outlined.Archive
17+
import androidx.compose.material.icons.outlined.Delete
1518
import androidx.compose.material.icons.outlined.Save
16-
import androidx.compose.material3.Checkbox
1719
import androidx.compose.material3.ExperimentalMaterial3Api
1820
import androidx.compose.material3.Icon
21+
import androidx.compose.material3.MaterialTheme
1922
import androidx.compose.material3.Text
2023
import androidx.compose.runtime.*
2124
import androidx.compose.ui.Alignment
2225
import androidx.compose.ui.Modifier
2326
import androidx.compose.ui.input.nestedscroll.nestedScroll
2427
import androidx.compose.ui.unit.dp
2528
import com.artemchep.keyguard.common.model.Loadable
29+
import com.artemchep.keyguard.common.model.create.CreateRequest
2630
import com.artemchep.keyguard.common.model.fold
2731
import com.artemchep.keyguard.common.model.getOrNull
2832
import com.artemchep.keyguard.feature.add.AddScreenItems
@@ -34,23 +38,30 @@ import com.artemchep.keyguard.feature.add.getAnyFieldShapeState
3438
import com.artemchep.keyguard.feature.filepicker.FileDropOverlay
3539
import com.artemchep.keyguard.feature.filepicker.FilePickerEffect
3640
import com.artemchep.keyguard.feature.filepicker.fileDropTarget
41+
import com.artemchep.keyguard.feature.home.vault.component.FlatDropdownSimpleExpressive
3742
import com.artemchep.keyguard.feature.home.vault.component.Section
43+
import com.artemchep.keyguard.feature.localization.TextHolder
3844
import com.artemchep.keyguard.feature.navigation.NavigationIcon
3945
import com.artemchep.keyguard.res.Res
4046
import com.artemchep.keyguard.res.*
4147
import com.artemchep.keyguard.ui.DefaultFab
4248
import com.artemchep.keyguard.ui.ExpandedIfNotEmpty
4349
import com.artemchep.keyguard.ui.FabState
44-
import com.artemchep.keyguard.ui.FlatItemLayout
50+
import com.artemchep.keyguard.ui.FlatItemAction
51+
import com.artemchep.keyguard.ui.FlatItemTextContent
4552
import com.artemchep.keyguard.ui.FlatSimpleNote
4653
import com.artemchep.keyguard.ui.OptionsButton
4754
import com.artemchep.keyguard.ui.ScaffoldLazyColumn
4855
import com.artemchep.keyguard.ui.button.FavouriteToggleButton
56+
import com.artemchep.keyguard.ui.icons.DropdownIcon
57+
import com.artemchep.keyguard.ui.icons.Stub
58+
import com.artemchep.keyguard.ui.icons.icon
4959
import com.artemchep.keyguard.ui.shimmer.shimmer
5060
import com.artemchep.keyguard.ui.skeleton.SkeletonText
5161
import com.artemchep.keyguard.ui.theme.Dimens
5262
import com.artemchep.keyguard.ui.toolbar.LargeToolbar
5363
import com.artemchep.keyguard.ui.toolbar.util.ToolbarBehavior
64+
import kotlinx.collections.immutable.toImmutableList
5465
import org.jetbrains.compose.resources.stringResource
5566

5667
@Composable
@@ -384,21 +395,63 @@ private fun AddScreenMergeItem(
384395
modifier = Modifier
385396
.height(8.dp),
386397
)
387-
FlatItemLayout(
388-
leading = {
389-
Checkbox(
390-
checked = state.removeOrigin.checked,
391-
onCheckedChange = null,
392-
)
393-
},
398+
399+
val updatedOnChangeState by rememberUpdatedState(state.onChangePostAction)
400+
val postActionIconImageVector = remember(state.postAction) {
401+
state.postAction.iconImageVector()
402+
.takeIf { it !== Icons.Stub }
403+
}
404+
val postActionTitleStringRes = remember(state.postAction) {
405+
state.postAction.titleStringRes()
406+
}
407+
val postActionDropdown = remember {
408+
sequence {
409+
yield(null)
410+
yieldAll(CreateRequest.Merge.PostAction.entries)
411+
}
412+
.map { postAction ->
413+
val titleRes = postAction.titleStringRes()
414+
val icon = postAction.iconImageVector()
415+
FlatItemAction(
416+
icon = icon,
417+
title = TextHolder.Res(titleRes),
418+
onClick = {
419+
updatedOnChangeState?.invoke(postAction)
420+
},
421+
)
422+
}
423+
.toImmutableList()
424+
}
425+
FlatDropdownSimpleExpressive(
426+
dropdown = postActionDropdown,
427+
leading = postActionIconImageVector
428+
?.let { icon ->
429+
icon<RowScope>(icon)
430+
},
394431
content = {
395-
Text(
396-
text = stringResource(Res.string.additem_merge_remove_origin_ciphers_title),
432+
FlatItemTextContent(
433+
title = {
434+
Text(
435+
text = stringResource(postActionTitleStringRes),
436+
style = MaterialTheme.typography.titleMedium,
437+
)
438+
},
397439
)
398440
},
399-
onClick = {
400-
val newValue = !state.removeOrigin.checked
401-
state.removeOrigin.onChange?.invoke(newValue)
441+
trailing = {
442+
DropdownIcon()
402443
},
403444
)
404445
}
446+
447+
private fun CreateRequest.Merge.PostAction?.titleStringRes() = when (this) {
448+
null -> Res.string.additem_merge_keep_origin_ciphers_title
449+
CreateRequest.Merge.PostAction.TRASH -> Res.string.additem_merge_remove_origin_ciphers_title
450+
CreateRequest.Merge.PostAction.ARCHIVE -> Res.string.additem_merge_archive_origin_ciphers_title
451+
}
452+
453+
private fun CreateRequest.Merge.PostAction?.iconImageVector() = when (this) {
454+
null -> Icons.Stub
455+
CreateRequest.Merge.PostAction.TRASH -> Icons.Outlined.Delete
456+
CreateRequest.Merge.PostAction.ARCHIVE -> Icons.Outlined.Archive
457+
}

common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/AddState.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ data class AddState(
6868
data class Merge(
6969
val ciphers: List<DSecret>,
7070
val note: SimpleNote?,
71-
val removeOrigin: SwitchFieldModel,
71+
val postAction: CreateRequest.Merge.PostAction?,
72+
val onChangePostAction: ((CreateRequest.Merge.PostAction?) -> Unit)? = null,
7273
)
7374
}

common/src/commonMain/kotlin/com/artemchep/keyguard/feature/home/vault/add/AddStateProducer.kt

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -304,19 +304,16 @@ fun produceAddScreenState(
304304
else -> null
305305
}
306306

307-
val mergeRemoveCiphers = mutablePersistedFlow("merge.remove_ciphers") {
308-
false
307+
val mergePostActionSink = mutablePersistedFlow<CreateRequest.Merge.PostAction?>("merge.post_action") {
308+
null
309309
}
310-
mergeRemoveCiphers
311-
.map { removeCiphers ->
312-
val removeOrigin = SwitchFieldModel(
313-
checked = removeCiphers,
314-
onChange = mergeRemoveCiphers::value::set,
315-
)
310+
mergePostActionSink
311+
.map { mergePostAction ->
316312
AddState.Merge(
317313
ciphers = args.merge.ciphers,
318314
note = note,
319-
removeOrigin = removeOrigin,
315+
postAction = mergePostAction,
316+
onChangePostAction = mergePostActionSink::value::set,
320317
)
321318
}
322319
} else {
@@ -940,7 +937,7 @@ fun produceAddScreenState(
940937

941938
val requestMerge = CreateRequest.Merge(
942939
ciphers = merge.ciphers,
943-
removeOrigin = merge.removeOrigin.checked,
940+
postAction = merge.postAction,
944941
)
945942
return r.copy(merge = requestMerge)
946943
}

common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/AddCipher.kt

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ import com.artemchep.keyguard.common.io.map
1111
import com.artemchep.keyguard.common.model.AccountId
1212
import com.artemchep.keyguard.common.model.DSecret
1313
import com.artemchep.keyguard.common.model.canDelete
14+
import com.artemchep.keyguard.common.model.canEdit
1415
import com.artemchep.keyguard.common.model.create.CreateRequest
1516
import com.artemchep.keyguard.common.service.crypto.CryptoGenerator
1617
import com.artemchep.keyguard.common.service.text.Base64Service
1718
import com.artemchep.keyguard.common.usecase.AddCipher
1819
import com.artemchep.keyguard.common.usecase.AddFolder
20+
import com.artemchep.keyguard.common.usecase.ArchiveCipherById
1921
import com.artemchep.keyguard.common.usecase.GetPasswordStrength
2022
import com.artemchep.keyguard.common.usecase.TrashCipherById
2123
import com.artemchep.keyguard.core.store.bitwarden.BitwardenCipher
@@ -42,6 +44,7 @@ import org.kodein.di.instance
4244
class AddCipherImpl(
4345
private val modifyDatabase: ModifyDatabase,
4446
private val addFolder: AddFolder,
47+
private val archiveCipherById: ArchiveCipherById,
4548
private val trashCipherById: TrashCipherById,
4649
private val cryptoGenerator: CryptoGenerator,
4750
private val getPasswordStrength: GetPasswordStrength,
@@ -56,6 +59,7 @@ class AddCipherImpl(
5659
constructor(directDI: DirectDI) : this(
5760
modifyDatabase = directDI.instance(),
5861
addFolder = directDI.instance(),
62+
archiveCipherById = directDI.instance(),
5963
trashCipherById = directDI.instance(),
6064
cryptoGenerator = directDI.instance(),
6165
getPasswordStrength = directDI.instance(),
@@ -176,32 +180,46 @@ class AddCipherImpl(
176180
}
177181
}
178182
}.flatTap {
179-
val cipherIdsToTrash = cipherIdsToRequests
180-
.values
181-
.asSequence()
182-
.mapNotNull {
183-
val ciphers = it.merge?.ciphers
184-
?: return@mapNotNull null
185-
// Ignore the request if we do not need to trash the
186-
// origin ciphers.
187-
if (!it.merge.removeOrigin) {
188-
return@mapNotNull null
183+
val ciphersIdsToTrash = mutableSetOf<String>()
184+
val ciphersIdsToArchive = mutableSetOf<String>()
185+
186+
cipherIdsToRequests.values.forEach {
187+
val ciphers = it.merge?.ciphers
188+
?: return@forEach
189+
190+
val postAction = it.merge.postAction
191+
?: return@forEach
192+
when (postAction) {
193+
CreateRequest.Merge.PostAction.TRASH -> {
194+
ciphers.forEach { cipher ->
195+
val allow = cipher.canDelete() &&
196+
!cipher.deleted
197+
if (!allow) return@forEach
198+
ciphersIdsToTrash += cipher.id
199+
}
200+
}
201+
202+
CreateRequest.Merge.PostAction.ARCHIVE -> {
203+
ciphers.forEach { cipher ->
204+
val allow = cipher.canEdit() &&
205+
!cipher.archived
206+
if (!allow) return@forEach
207+
ciphersIdsToArchive += cipher.id
208+
}
189209
}
190-
ciphers
191210
}
192-
.flatten()
193-
.filter { cipher ->
194-
cipher.canDelete() &&
195-
!cipher.deleted
211+
}
212+
213+
ioEffect {
214+
if (ciphersIdsToArchive.isNotEmpty()) {
215+
archiveCipherById(ciphersIdsToArchive)
216+
.bind()
196217
}
197-
.map { cipher ->
198-
cipher.id
218+
if (ciphersIdsToTrash.isNotEmpty()) {
219+
trashCipherById(ciphersIdsToTrash)
220+
.bind()
199221
}
200-
.toSet()
201-
if (cipherIdsToTrash.isEmpty()) {
202-
return@flatTap ioUnit()
203222
}
204-
trashCipherById(cipherIdsToTrash)
205223
}
206224
}
207225

common/src/commonMain/kotlin/com/artemchep/keyguard/provider/bitwarden/usecase/ArchiveCipherById.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.artemchep.keyguard.provider.bitwarden.usecase
22

33
import com.artemchep.keyguard.common.io.IO
4+
import com.artemchep.keyguard.common.io.bind
45
import com.artemchep.keyguard.common.io.map
56
import com.artemchep.keyguard.common.usecase.ArchiveCipherById
67
import com.artemchep.keyguard.core.store.bitwarden.BitwardenCipher
@@ -18,6 +19,10 @@ class ArchiveCipherByIdImpl(
1819
) : ArchiveCipherById {
1920
companion object {
2021
private const val TAG = "ArchiveCipherById.bitwarden"
22+
23+
// As of right now it seems like Bitwarden is not enforcing the
24+
// archive functionality under the paywall lock.
25+
private const val RESPECT_BW_ARCHIVE_PREMIUM_GATE = false
2126
}
2227

2328
constructor(directDI: DirectDI) : this(
@@ -35,6 +40,15 @@ class ArchiveCipherByIdImpl(
3540
) = modifyCipherById(
3641
cipherIds,
3742
) { model ->
43+
if (RESPECT_BW_ARCHIVE_PREMIUM_GATE) {
44+
// Bitwarden archive/un-archive functionality is
45+
// gated behind the paywall.
46+
val premium = hasPremium.bind()
47+
if (!premium) {
48+
return@modifyCipherById model
49+
}
50+
}
51+
3852
var new = model
3953
// Add the archived instant to mark the model as archived.
4054
val now = Clock.System.now()

0 commit comments

Comments
 (0)