Add VaultTakeoverScreen and ViewModel

Create the VaultTakeoverScreen composable and ViewModel following MVVM + UDF architecture:

- VaultTakeoverViewModel: Handles user actions (Continue, Decline, Help) and emits navigation events
- VaultTakeoverScreen: Full-screen informational UI with organization transfer messaging
- VaultTakeoverState: Holds organization name (stubbed with TODO)
- VaultTakeoverEvent: Navigation events for vault, leave org, and help URI
- VaultTakeoverAction: User action types

Screen UI includes:
- Placeholder illustration (using ic_bw_passkey temporarily)
- Title and description text with org name placeholders
- Continue button (primary action)
- Decline and leave button (secondary action)
- Help link ("Why am I seeing this?")
- Proper spacing and scrollable layout
- Extracted text and action components for code length compliance

Also fixed: Renamed 'continue' string resource to 'continue_label' to avoid Java keyword conflict.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Patrick Honkonen 2025-12-05 16:32:39 -05:00
parent e11f20c466
commit 430baa2ff8
No known key found for this signature in database
GPG Key ID: 27C65CF8B03CC9FB
3 changed files with 277 additions and 1 deletions

View File

@ -0,0 +1,192 @@
package com.x8bit.bitwarden.ui.vault.feature.vaulttakeover
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
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.navigationBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
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.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.composition.LocalIntentManager
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Top level screen component for the VaultTakeover screen.
*/
@Composable
fun VaultTakeoverScreen(
onNavigateToVault: () -> Unit,
onNavigateToLeaveOrganization: () -> Unit,
viewModel: VaultTakeoverViewModel = hiltViewModel(),
intentManager: IntentManager = LocalIntentManager.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
EventsEffect(viewModel = viewModel) { event ->
when (event) {
VaultTakeoverEvent.NavigateToVault -> onNavigateToVault()
VaultTakeoverEvent.NavigateToLeaveOrganization -> onNavigateToLeaveOrganization()
is VaultTakeoverEvent.LaunchUri -> intentManager.launchUri(event.uri.toUri())
}
}
val onContinueClick = remember(viewModel) {
{ viewModel.trySendAction(VaultTakeoverAction.ContinueClicked) }
}
val onDeclineClick = remember(viewModel) {
{ viewModel.trySendAction(VaultTakeoverAction.DeclineAndLeaveClicked) }
}
val onHelpClick = remember(viewModel) {
{ viewModel.trySendAction(VaultTakeoverAction.HelpLinkClicked) }
}
BitwardenScaffold {
VaultTakeoverContent(
organizationName = state.organizationName,
onContinueClick = onContinueClick,
onDeclineClick = onDeclineClick,
onHelpClick = onHelpClick,
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
)
}
}
@Composable
private fun VaultTakeoverContent(
organizationName: String,
onContinueClick: () -> Unit,
onDeclineClick: () -> Unit,
onHelpClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
) {
Spacer(modifier = Modifier.height(32.dp))
Image(
painter = painterResource(id = BitwardenDrawable.ic_bw_passkey),
contentDescription = null,
modifier = Modifier
.size(100.dp)
.align(Alignment.CenterHorizontally),
)
Spacer(modifier = Modifier.height(24.dp))
VaultTakeoverTextContent(organizationName = organizationName)
Spacer(modifier = Modifier.height(24.dp))
VaultTakeoverActions(
onContinueClick = onContinueClick,
onDeclineClick = onDeclineClick,
onHelpClick = onHelpClick,
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Composable
private fun VaultTakeoverTextContent(
organizationName: String,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Text(
text = stringResource(
id = BitwardenString.transfer_items_to_org,
organizationName,
),
style = BitwardenTheme.typography.titleMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(
id = BitwardenString.transfer_items_description,
organizationName,
),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
}
@Composable
private fun VaultTakeoverActions(
onContinueClick: () -> Unit,
onDeclineClick: () -> Unit,
onHelpClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
BitwardenFilledButton(
label = stringResource(id = BitwardenString.continue_label),
onClick = onContinueClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenOutlinedButton(
label = stringResource(id = BitwardenString.decline_and_leave),
onClick = onDeclineClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenTextButton(
label = stringResource(id = BitwardenString.why_am_i_seeing_this),
onClick = onHelpClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
}
}
@Preview(showBackground = true)
@Composable
private fun VaultTakeoverScreen_preview() {
BitwardenTheme {
VaultTakeoverContent(
organizationName = "Test Organization",
onContinueClick = {},
onDeclineClick = {},
onHelpClick = {},
)
}
}

View File

@ -0,0 +1,84 @@
package com.x8bit.bitwarden.ui.vault.feature.vaulttakeover
import com.bitwarden.ui.platform.base.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* View model for the [VaultTakeoverScreen].
*/
@HiltViewModel
class VaultTakeoverViewModel @Inject constructor(
// TODO: Inject required repositories/managers
) : BaseViewModel<VaultTakeoverState, VaultTakeoverEvent, VaultTakeoverAction>(
initialState = VaultTakeoverState(
organizationName = "TODO", // TODO: Get from navigation args or repository
),
) {
override fun handleAction(action: VaultTakeoverAction) {
when (action) {
VaultTakeoverAction.ContinueClicked -> handleContinueClicked()
VaultTakeoverAction.DeclineAndLeaveClicked -> handleDeclineAndLeaveClicked()
VaultTakeoverAction.HelpLinkClicked -> handleHelpLinkClicked()
}
}
private fun handleContinueClicked() {
sendEvent(VaultTakeoverEvent.NavigateToVault)
}
private fun handleDeclineAndLeaveClicked() {
sendEvent(VaultTakeoverEvent.NavigateToLeaveOrganization)
}
private fun handleHelpLinkClicked() {
sendEvent(VaultTakeoverEvent.LaunchUri("TODO_HELP_URL"))
}
}
/**
* Models the state for the [VaultTakeoverScreen].
*/
data class VaultTakeoverState(
val organizationName: String,
)
/**
* Models the events that can be sent from the [VaultTakeoverViewModel].
*/
sealed class VaultTakeoverEvent {
/**
* Navigate to the vault screen after accepting takeover.
*/
data object NavigateToVault : VaultTakeoverEvent()
/**
* Navigate to the leave organization flow after declining.
*/
data object NavigateToLeaveOrganization : VaultTakeoverEvent()
/**
* Launch a URI in the browser or appropriate handler.
*/
data class LaunchUri(val uri: String) : VaultTakeoverEvent()
}
/**
* Models the actions that can be handled by the [VaultTakeoverViewModel].
*/
sealed class VaultTakeoverAction {
/**
* User clicked the Continue button.
*/
data object ContinueClicked : VaultTakeoverAction()
/**
* User clicked the Decline and Leave button.
*/
data object DeclineAndLeaveClicked : VaultTakeoverAction()
/**
* User clicked the "Why am I seeing this?" help link.
*/
data object HelpLinkClicked : VaultTakeoverAction()
}

View File

@ -1163,7 +1163,7 @@ Do you want to switch to this account?</string>
<string name="resending">Resending</string>
<string name="transfer_items_to_org">Transfer items to %1$s</string>
<string name="transfer_items_description">%1$s is requiring all items to be owned by the organization for security and compliance. Click accept to transfer ownership of your items.</string>
<string name="continue">Continue</string>
<string name="continue_label">Continue</string>
<string name="decline_and_leave">Decline and leave</string>
<string name="why_am_i_seeing_this">Why am I seeing this?</string>
</resources>