Skip to content

Commit afc1ff4

Browse files
PM-31042: Add overflow archive button (#6385)
1 parent 8cb4fab commit afc1ff4

28 files changed

+1039
-72
lines changed

app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchContent.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ fun SearchContent(
7474
is ListingItemOverflowAction.VaultAction.EditClick,
7575
is ListingItemOverflowAction.VaultAction.LaunchClick,
7676
is ListingItemOverflowAction.VaultAction.ViewClick,
77+
is ListingItemOverflowAction.VaultAction.ArchiveClick,
7778
null,
7879
-> Unit
7980
}

app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchScreen.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import com.bitwarden.ui.platform.components.content.BitwardenErrorContent
2323
import com.bitwarden.ui.platform.components.content.BitwardenLoadingContent
2424
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
2525
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
26+
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
2627
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
2728
import com.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost
2829
import com.bitwarden.ui.platform.components.snackbar.model.rememberBitwardenSnackbarHostState
@@ -118,6 +119,7 @@ fun SearchScreen(
118119
SearchDialogs(
119120
dialogState = state.dialogState,
120121
onDismissRequest = searchHandlers.onDismissRequest,
122+
onUpgradeToPremiumClick = searchHandlers.onUpgradeToPremiumClick,
121123
)
122124

123125
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
@@ -192,8 +194,21 @@ fun SearchScreen(
192194
private fun SearchDialogs(
193195
dialogState: SearchState.DialogState?,
194196
onDismissRequest: () -> Unit,
197+
onUpgradeToPremiumClick: () -> Unit,
195198
) {
196199
when (dialogState) {
200+
SearchState.DialogState.ArchiveRequiresPremium -> {
201+
BitwardenTwoButtonDialog(
202+
title = stringResource(id = BitwardenString.archive_unavailable),
203+
message = stringResource(id = BitwardenString.archiving_items_is_a_premium_feature),
204+
confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium),
205+
dismissButtonText = stringResource(id = BitwardenString.cancel),
206+
onConfirmClick = onUpgradeToPremiumClick,
207+
onDismissClick = onDismissRequest,
208+
onDismissRequest = onDismissRequest,
209+
)
210+
}
211+
197212
is SearchState.DialogState.Error -> BitwardenBasicDialog(
198213
title = dialogState.title?.invoke(),
199214
message = dialogState.message(),

app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/SearchViewModel.kt

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import android.os.Parcelable
44
import androidx.lifecycle.SavedStateHandle
55
import androidx.lifecycle.viewModelScope
66
import com.bitwarden.annotation.OmitFromCoverage
7+
import com.bitwarden.core.data.manager.model.FlagKey
78
import com.bitwarden.core.data.repository.model.DataState
89
import com.bitwarden.data.repository.util.baseIconUrl
910
import com.bitwarden.data.repository.util.baseWebSendUrl
11+
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
1012
import com.bitwarden.network.model.PolicyTypeJson
1113
import com.bitwarden.send.SendType
1214
import com.bitwarden.ui.platform.base.BackgroundEvent
@@ -28,6 +30,7 @@ import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilitySele
2830
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
2931
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
3032
import com.x8bit.bitwarden.data.autofill.util.login
33+
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
3134
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
3235
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
3336
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
@@ -40,6 +43,7 @@ import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
4043
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
4144
import com.x8bit.bitwarden.data.vault.manager.model.GetCipherResult
4245
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
46+
import com.x8bit.bitwarden.data.vault.repository.model.ArchiveCipherResult
4347
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
4448
import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
4549
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
@@ -95,10 +99,11 @@ class SearchViewModel @Inject constructor(
9599
private val organizationEventManager: OrganizationEventManager,
96100
private val vaultRepo: VaultRepository,
97101
private val authRepo: AuthRepository,
98-
environmentRepo: EnvironmentRepository,
102+
private val environmentRepo: EnvironmentRepository,
99103
settingsRepo: SettingsRepository,
100104
snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
101105
specialCircumstanceManager: SpecialCircumstanceManager,
106+
featureFlagManager: FeatureFlagManager,
102107
) : BaseViewModel<SearchState, SearchEvent, SearchAction>(
103108
// We load the state from the savedStateHandle for testing purposes.
104109
initialState = savedStateHandle[KEY_STATE]
@@ -134,6 +139,7 @@ class SearchViewModel @Inject constructor(
134139
hasMasterPassword = userState.activeAccount.hasMasterPassword,
135140
isPremium = userState.activeAccount.isPremium,
136141
restrictItemTypesPolicyOrgIds = persistentListOf(),
142+
isArchiveEnabled = featureFlagManager.getFeatureFlag(FlagKey.ArchiveItems),
137143
)
138144
},
139145
) {
@@ -171,6 +177,12 @@ class SearchViewModel @Inject constructor(
171177
.map { SearchAction.Internal.SnackbarDataReceived(it) }
172178
.onEach(::sendAction)
173179
.launchIn(viewModelScope)
180+
181+
featureFlagManager
182+
.getFeatureFlagFlow(FlagKey.ArchiveItems)
183+
.map { SearchAction.Internal.ArchiveItemsFlagUpdateReceive(it) }
184+
.onEach(::sendAction)
185+
.launchIn(viewModelScope)
174186
}
175187

176188
override fun handleAction(action: SearchAction) {
@@ -187,6 +199,7 @@ class SearchViewModel @Inject constructor(
187199
is SearchAction.SearchTermChange -> handleSearchTermChange(action)
188200
is SearchAction.VaultFilterSelect -> handleVaultFilterSelect(action)
189201
is SearchAction.OverflowOptionClick -> handleOverflowItemClick(action)
202+
SearchAction.UpgradeToPremiumClick -> handleUpgradeToPremiumClick()
190203
is SearchAction.Internal -> handleInternalAction(action)
191204
}
192205
}
@@ -300,6 +313,13 @@ class SearchViewModel @Inject constructor(
300313
recalculateViewState()
301314
}
302315

316+
private fun handleUpgradeToPremiumClick() {
317+
mutableStateFlow.update { it.copy(dialogState = null) }
318+
val baseUrl = environmentRepo.environment.environmentUrlData.baseWebVaultUrlOrDefault
319+
val url = "$baseUrl/#/settings/subscription/premium?callToAction=upgradeToPremium"
320+
sendEvent(SearchEvent.NavigateToUrl(url = url))
321+
}
322+
303323
private fun handleOverflowItemClick(action: SearchAction.OverflowOptionClick) {
304324
when (val overflowAction = action.overflowAction) {
305325
is ListingItemOverflowAction.SendAction.CopyUrlClick -> {
@@ -355,6 +375,10 @@ class SearchViewModel @Inject constructor(
355375
is ListingItemOverflowAction.VaultAction.CopyTotpClick -> {
356376
handleCopyTotpClick(overflowAction)
357377
}
378+
379+
is ListingItemOverflowAction.VaultAction.ArchiveClick -> {
380+
handleArchiveClick(overflowAction)
381+
}
358382
}
359383
}
360384

@@ -404,6 +428,34 @@ class SearchViewModel @Inject constructor(
404428
}
405429
}
406430

431+
private fun handleArchiveClick(action: ListingItemOverflowAction.VaultAction.ArchiveClick) {
432+
if (!state.isPremium) {
433+
mutableStateFlow.update {
434+
it.copy(dialogState = SearchState.DialogState.ArchiveRequiresPremium)
435+
}
436+
return
437+
}
438+
mutableStateFlow.update {
439+
it.copy(
440+
dialogState = SearchState.DialogState.Loading(
441+
message = BitwardenString.archiving.asText(),
442+
),
443+
)
444+
}
445+
viewModelScope.launch {
446+
decryptCipherViewOrNull(cipherId = action.cipherId)?.let {
447+
sendAction(
448+
SearchAction.Internal.ArchiveCipherReceive(
449+
result = vaultRepo.archiveCipher(
450+
cipherId = action.cipherId,
451+
cipherView = it,
452+
),
453+
),
454+
)
455+
}
456+
}
457+
}
458+
407459
private fun handleRemovePasswordClick(
408460
action: ListingItemOverflowAction.SendAction.RemovePasswordClick,
409461
) {
@@ -558,6 +610,12 @@ class SearchViewModel @Inject constructor(
558610
is SearchAction.Internal.DecryptCipherErrorReceive -> {
559611
handleDecryptCipherErrorReceive(action)
560612
}
613+
614+
is SearchAction.Internal.ArchiveItemsFlagUpdateReceive -> {
615+
handleArchiveItemsFlagUpdateReceive(action)
616+
}
617+
618+
is SearchAction.Internal.ArchiveCipherReceive -> handleArchiveCipherReceive(action)
561619
}
562620
}
563621

@@ -575,6 +633,33 @@ class SearchViewModel @Inject constructor(
575633
}
576634
}
577635

636+
private fun handleArchiveItemsFlagUpdateReceive(
637+
action: SearchAction.Internal.ArchiveItemsFlagUpdateReceive,
638+
) {
639+
mutableStateFlow.update { it.copy(isArchiveEnabled = action.isEnabled) }
640+
}
641+
642+
private fun handleArchiveCipherReceive(action: SearchAction.Internal.ArchiveCipherReceive) {
643+
when (val result = action.result) {
644+
is ArchiveCipherResult.Error -> {
645+
mutableStateFlow.update {
646+
it.copy(
647+
dialogState = SearchState.DialogState.Error(
648+
title = BitwardenString.an_error_has_occurred.asText(),
649+
message = BitwardenString.unable_to_archive_selected_item.asText(),
650+
throwable = result.error,
651+
),
652+
)
653+
}
654+
}
655+
656+
ArchiveCipherResult.Success -> {
657+
mutableStateFlow.update { it.copy(dialogState = null) }
658+
sendEvent(SearchEvent.ShowSnackbar(BitwardenString.item_archived.asText()))
659+
}
660+
}
661+
}
662+
578663
private fun handleIconLoadingSettingReceive(
579664
action: SearchAction.Internal.IconLoadingSettingReceive,
580665
) {
@@ -879,6 +964,7 @@ class SearchViewModel @Inject constructor(
879964
isIconLoadingDisabled = state.isIconLoadingDisabled,
880965
isAutofill = state.isAutofill,
881966
isPremiumUser = state.isPremium,
967+
isArchiveEnabled = state.isArchiveEnabled,
882968
)
883969
}
884970

@@ -945,6 +1031,7 @@ data class SearchState(
9451031
val hasMasterPassword: Boolean,
9461032
val isPremium: Boolean,
9471033
val restrictItemTypesPolicyOrgIds: ImmutableList<String>,
1034+
val isArchiveEnabled: Boolean,
9481035
) : Parcelable {
9491036

9501037
/**
@@ -1027,6 +1114,12 @@ data class SearchState(
10271114
data class Loading(
10281115
val message: Text,
10291116
) : DialogState()
1117+
1118+
/**
1119+
* Displays a dialog to the user indicating that archiving requires a premium account.
1120+
*/
1121+
@Parcelize
1122+
data object ArchiveRequiresPremium : DialogState()
10301123
}
10311124

10321125
/**
@@ -1296,6 +1389,11 @@ sealed class SearchAction {
12961389
val overflowAction: ListingItemOverflowAction,
12971390
) : SearchAction()
12981391

1392+
/**
1393+
* User clicked the upgrade to premium button.
1394+
*/
1395+
data object UpgradeToPremiumClick : SearchAction()
1396+
12991397
/**
13001398
* Models actions that the [SearchViewModel] itself might send.
13011399
*/
@@ -1321,6 +1419,13 @@ sealed class SearchAction {
13211419
val result: GenerateTotpResult,
13221420
) : Internal()
13231421

1422+
/**
1423+
* Indicates that the archive cipher result has been received.
1424+
*/
1425+
data class ArchiveCipherReceive(
1426+
val result: ArchiveCipherResult,
1427+
) : Internal()
1428+
13241429
/**
13251430
* Indicates a result for removing the password protection from a send has been received.
13261431
*/
@@ -1372,6 +1477,13 @@ sealed class SearchAction {
13721477
data class DecryptCipherErrorReceive(
13731478
val error: Throwable?,
13741479
) : Internal()
1480+
1481+
/**
1482+
* Indicates that the Archive Items flag has been updated.
1483+
*/
1484+
data class ArchiveItemsFlagUpdateReceive(
1485+
val isEnabled: Boolean,
1486+
) : Internal()
13751487
}
13761488
}
13771489

app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/handlers/SearchHandlers.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ data class SearchHandlers(
2020
val onSearchTermChange: (String) -> Unit,
2121
val onVaultFilterSelect: (VaultFilterType) -> Unit,
2222
val onOverflowItemClick: (ListingItemOverflowAction) -> Unit,
23+
val onUpgradeToPremiumClick: () -> Unit,
2324
) {
2425
@Suppress("UndocumentedPublicClass")
2526
companion object {
@@ -55,6 +56,9 @@ data class SearchHandlers(
5556
onOverflowItemClick = {
5657
viewModel.trySendAction(SearchAction.OverflowOptionClick(it))
5758
},
59+
onUpgradeToPremiumClick = {
60+
viewModel.trySendAction(SearchAction.UpgradeToPremiumClick)
61+
},
5862
)
5963
}
6064
}

app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/search/util/SearchTypeDataExtensions.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ fun List<CipherListView>.toViewState(
186186
isIconLoadingDisabled: Boolean,
187187
isAutofill: Boolean,
188188
isPremiumUser: Boolean,
189+
isArchiveEnabled: Boolean,
189190
): SearchState.ViewState =
190191
when {
191192
searchTerm.isEmpty() -> SearchState.ViewState.Empty(message = null)
@@ -197,6 +198,7 @@ fun List<CipherListView>.toViewState(
197198
isIconLoadingDisabled = isIconLoadingDisabled,
198199
isAutofill = isAutofill,
199200
isPremiumUser = isPremiumUser,
201+
isArchiveEnabled = isArchiveEnabled,
200202
)
201203
.sortAlphabetically(),
202204
)
@@ -209,12 +211,14 @@ fun List<CipherListView>.toViewState(
209211
}
210212
}
211213

214+
@Suppress("LongParameterList")
212215
private fun List<CipherListView>.toDisplayItemList(
213216
baseIconUrl: String,
214217
hasMasterPassword: Boolean,
215218
isIconLoadingDisabled: Boolean,
216219
isAutofill: Boolean,
217220
isPremiumUser: Boolean,
221+
isArchiveEnabled: Boolean,
218222
): List<SearchState.DisplayItem> =
219223
this.map {
220224
it.toDisplayItem(
@@ -223,15 +227,18 @@ private fun List<CipherListView>.toDisplayItemList(
223227
isIconLoadingDisabled = isIconLoadingDisabled,
224228
isAutofill = isAutofill,
225229
isPremiumUser = isPremiumUser,
230+
isArchiveEnabled = isArchiveEnabled,
226231
)
227232
}
228233

234+
@Suppress("LongParameterList")
229235
private fun CipherListView.toDisplayItem(
230236
baseIconUrl: String,
231237
hasMasterPassword: Boolean,
232238
isIconLoadingDisabled: Boolean,
233239
isAutofill: Boolean,
234240
isPremiumUser: Boolean,
241+
isArchiveEnabled: Boolean,
235242
): SearchState.DisplayItem =
236243
SearchState.DisplayItem(
237244
id = id.orEmpty(),
@@ -247,6 +254,7 @@ private fun CipherListView.toDisplayItem(
247254
overflowOptions = toOverflowActions(
248255
hasMasterPassword = hasMasterPassword,
249256
isPremiumUser = isPremiumUser,
257+
isArchiveEnabled = isArchiveEnabled,
250258
),
251259
overflowTestTag = "CipherOptionsButton",
252260
totpCode = login?.totp,

app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ fun VaultItemListingContent(
7777
is ListingItemOverflowAction.VaultAction.LaunchClick,
7878
is ListingItemOverflowAction.VaultAction.ViewClick,
7979
is ListingItemOverflowAction.VaultAction.CopyTotpClick,
80+
is ListingItemOverflowAction.VaultAction.ArchiveClick,
8081
null,
8182
-> Unit
8283
}

app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,18 @@ private fun VaultItemListingDialogs(
404404
)
405405
}
406406

407+
is VaultItemListingState.DialogState.ArchiveRequiresPremium -> {
408+
BitwardenTwoButtonDialog(
409+
title = stringResource(id = BitwardenString.archive_unavailable),
410+
message = stringResource(id = BitwardenString.archiving_items_is_a_premium_feature),
411+
confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium),
412+
dismissButtonText = stringResource(id = BitwardenString.cancel),
413+
onConfirmClick = vaultItemListingHandlers.upgradeToPremiumClick,
414+
onDismissClick = vaultItemListingHandlers.dismissDialogRequest,
415+
onDismissRequest = vaultItemListingHandlers.dismissDialogRequest,
416+
)
417+
}
418+
407419
null -> Unit
408420
}
409421
}

0 commit comments

Comments
 (0)