Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import com.bitwarden.core.data.manager.BuildInfoManager
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.cxf.importer.CredentialExchangeImporter
import com.bitwarden.cxf.importer.dsl.credentialExchangeImporter
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
import com.bitwarden.cxf.manager.dsl.credentialExchangeCompletionManager
import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeCompletionManager
import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeImporter
import com.bitwarden.ui.platform.composition.LocalIntentManager
import com.bitwarden.ui.platform.manager.IntentManager
Expand Down Expand Up @@ -64,6 +67,8 @@ fun LocalManagerProvider(
permissionsManager: PermissionsManager = PermissionsManagerImpl(activity = activity),
credentialExchangeImporter: CredentialExchangeImporter =
credentialExchangeImporter(activity = activity),
credentialExchangeCompletionManager: CredentialExchangeCompletionManager =
credentialExchangeCompletionManager(activity = activity),
content: @Composable () -> Unit,
) {
CompositionLocalProvider(
Expand All @@ -79,6 +84,7 @@ fun LocalManagerProvider(
LocalNfcManager provides nfcManager,
LocalPermissionsManager provides permissionsManager,
LocalCredentialExchangeImporter provides credentialExchangeImporter,
LocalCredentialExchangeCompletionManager provides credentialExchangeCompletionManager,
content = content,
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.component

import androidx.compose.foundation.clickable
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.defaultMinSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.bitwarden.ui.R
import com.bitwarden.ui.platform.base.util.cardStyle
import com.bitwarden.ui.platform.base.util.hexToColor
import com.bitwarden.ui.platform.base.util.toSafeOverlayColor
import com.bitwarden.ui.platform.base.util.toUnscaledTextUnit
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem

/**
* A composable that displays an account summary list item.
*
* @param item The account selection list item to display.
* @param cardStyle The card style to apply to the list item.
* @param modifier The modifier to apply to the list item.
* @param clickable Whether the list item should be clickable.
*/
@Suppress("LongMethod")
@Composable
fun AccountSummaryListItem(
item: AccountSelectionListItem,
cardStyle: CardStyle,
modifier: Modifier = Modifier,
clickable: Boolean,
onClick: (userId: String) -> Unit = {},
) {
Row(
modifier = modifier
.testTag("AccountSummaryListItem")
.defaultMinSize(minHeight = 60.dp)
.clickable(
onClick = { onClick(item.userId) },
enabled = clickable,
)
.cardStyle(
cardStyle = cardStyle,
paddingStart = 16.dp,
paddingEnd = 4.dp,
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(24.dp),
) {
Icon(
painter = rememberVectorPainter(
id = BitwardenDrawable.ic_account_initials_container,
),
contentDescription = null,
tint = item.avatarColorHex.hexToColor(),
modifier = Modifier.size(24.dp),
)
Text(
text = item.initials,
style = TextStyle(
fontSize = 11.dp.toUnscaledTextUnit(),
lineHeight = 13.dp.toUnscaledTextUnit(),
fontFamily = FontFamily(Font(R.font.dm_sans_bold)),
fontWeight = FontWeight.W600,
),
color = item.avatarColorHex.hexToColor().toSafeOverlayColor(),
)
}

Column(
modifier = Modifier.weight(1f),
) {
Text(
text = item.email,
style = BitwardenTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag("AccountEmailLabel"),
)

if (item.isItemRestricted) {
Text(
text = stringResource(
BitwardenString.import_restricted_unable_to_import_credit_cards,
),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
modifier = Modifier.testTag("AccountRestrictedLabel"),
)
}
}

Spacer(modifier = Modifier.width(8.dp))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.component

import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.stringResource
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.resource.BitwardenString

/**
* A reusable scaffold for the export items screen.
*
* @param navIcon The navigation icon to be displayed in the top app bar.
* @param onNavigationIconClick The action to be performed when the navigation icon is clicked.
* @param navigationIconContentDescription The content description for the navigation icon.
* @param scrollBehavior The scroll behavior to be used for the top app bar.
* @param modifier The modifier to be applied to the scaffold.
* @param content The content to be displayed inside the scaffold.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExportItemsScaffold(
Copy link
Collaborator

@david-livefront david-livefront Sep 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this buy us much?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what you mean.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth the custom component to wrap the toolbar?

Copy link
Contributor Author

@SaintPatrck SaintPatrck Sep 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah... buy us much. Got it.

It saves us from duplicating everything but the title and nav icon in every screen. Plus it makes the screens easier to consume (as a lowly human ๐Ÿค– ).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry about the typo... I fixed it ๐Ÿ˜„

Fair enough, carry on

navIcon: Painter,
onNavigationIconClick: () -> Unit,
navigationIconContentDescription: String,
scrollBehavior: TopAppBarScrollBehavior,
modifier: Modifier = Modifier,
content: @Composable () -> Unit = {},
) {
BitwardenScaffold(
topBar = {
BitwardenTopAppBar(
title = stringResource(BitwardenString.import_from_bitwarden),
onNavigationIconClick = onNavigationIconClick,
navigationIconContentDescription = navigationIconContentDescription,
navigationIcon = navIcon,
scrollBehavior = scrollBehavior,
)
},
modifier = modifier,
content = content,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.model

import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable

/**
* Represents a list item for selecting an account to export items from.
*/
@Serializable
@Parcelize
data class AccountSelectionListItem(
val userId: String,
val isItemRestricted: Boolean,
val avatarColorHex: String,
val initials: String,
val email: String,
) : Parcelable
Original file line number Diff line number Diff line change
@@ -1,15 +1,167 @@
package com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount

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.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.credentials.providerevents.exception.ImportCredentialsUnknownErrorException
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager
import com.bitwarden.cxf.manager.model.ExportCredentialsResult
import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeCompletionManager
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.base.util.toListItemCardStyle
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.x8bit.bitwarden.ui.vault.feature.exportitems.component.AccountSummaryListItem
import com.x8bit.bitwarden.ui.vault.feature.exportitems.component.ExportItemsScaffold
import com.x8bit.bitwarden.ui.vault.feature.exportitems.model.AccountSelectionListItem
import com.x8bit.bitwarden.ui.vault.feature.exportitems.selectaccount.handlers.rememberSelectAccountHandlers
import kotlinx.collections.immutable.persistentListOf

/**
* Top level screen for selecting an account to export items from.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SelectAccountScreen(
onAccountSelected: (id: String) -> Unit,
onAccountSelected: (userId: String) -> Unit,
viewModel: SelectAccountViewModel = hiltViewModel(),
credentialExchangeCompletionManager: CredentialExchangeCompletionManager =
LocalCredentialExchangeCompletionManager.current,
) {
// TODO: [PM-26095] Implement select account screen.
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val handlers = rememberSelectAccountHandlers(viewModel)
EventsEffect(viewModel) { event ->
when (event) {
SelectAccountEvent.CancelExport -> {
credentialExchangeCompletionManager
.completeCredentialExport(
exportResult = ExportCredentialsResult.Failure(
// TODO: [PM-26094] Use ImportCredentialsCancellationException once
// public.
error = ImportCredentialsUnknownErrorException(
errorMessage = "User cancelled import.",
),
),
)
}

is SelectAccountEvent.NavigateToPasswordVerification -> {
onAccountSelected(event.userId)
}
}
}

val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
ExportItemsScaffold(
navIcon = rememberVectorPainter(BitwardenDrawable.ic_close),
navigationIconContentDescription = stringResource(BitwardenString.close),
onNavigationIconClick = handlers.onCloseClick,
scrollBehavior = scrollBehavior,
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
) {
SelectAccountContent(
state = state,
onAccountClick = handlers.onAccountClick,
modifier = Modifier
.fillMaxSize()
.standardHorizontalMargin(),
)
}
}

@Composable
private fun SelectAccountContent(
state: SelectAccountState,
onAccountClick: (userId: String) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier) {
item { Spacer(Modifier.height(24.dp)) }

item {
Text(
text = stringResource(BitwardenString.select_account),
textAlign = TextAlign.Center,
style = BitwardenTheme.typography.titleMedium,
modifier = Modifier.fillMaxWidth(),
)
}

item { Spacer(Modifier.height(16.dp)) }

itemsIndexed(
items = state.accountSelectionListItems,
key = { _, item -> "AccountSummaryItem_${item.userId}" },
) { index, item ->
AccountSummaryListItem(
item = item,
cardStyle = state.accountSelectionListItems.toListItemCardStyle(index),
clickable = true,
onClick = onAccountClick,
modifier = Modifier
.fillMaxWidth()
.animateItem(),
)
}
item { Spacer(Modifier.height(16.dp)) }
}
}

@Preview(showBackground = true)
@Composable
private fun SelectAccountContentPreview() {
val state = SelectAccountState(
accountSelectionListItems = persistentListOf(
AccountSelectionListItem(
userId = "1",
email = "john.doe@example.com",
initials = "JD",
avatarColorHex = "#FFFF0000",
isItemRestricted = false,
),
AccountSelectionListItem(
userId = "2",
email = "jane.smith@example.com",
initials = "JS",
avatarColorHex = "#FF00FF00",
isItemRestricted = true,
),
AccountSelectionListItem(
userId = "3",
email = "another.user@example.com",
initials = "AU",
avatarColorHex = "#FF0000FF",
isItemRestricted = false,
),
),
)
BitwardenScaffold {
SelectAccountContent(
state = state,
onAccountClick = { },
)
}
}
Loading