Add a two-pane login view details view.

This commit is contained in:
Greyson Parrelli
2026-09-21 11:11:21 -04:00
parent 7fd4e9b14a
commit 2479df6d8b
2 changed files with 265 additions and 101 deletions
@@ -58,6 +58,18 @@ class SignalLoginViewDetailsScreenTest {
composeTestRule.onNodeWithTag(SignalLoginTestTags.BETA_DISCLAIMER).assertIsDisplayed()
}
@Config(qualifiers = "w1280dp-h800dp-xhdpi")
@Test
fun `when the window is wide, both panes are shown`() {
setContent(showResetRecoveryKeyButton = true)
composeTestRule.onNodeWithTag(SignalLoginTestTags.BETA_DISCLAIMER).assertIsDisplayed()
composeTestRule.onNodeWithTag(SignalLoginTestTags.KEY_DETAILS_ACCOUNT_ID_BLOCK).assertIsDisplayed()
composeTestRule.onNodeWithTag(SignalLoginTestTags.KEY_DETAILS_RECOVERY_KEY_BLOCK).assertIsDisplayed()
composeTestRule.onNodeWithTag(SignalLoginTestTags.VIEW_DETAILS_SAVE_TO_PASSWORD_MANAGER_BUTTON).assertIsDisplayed()
composeTestRule.onNodeWithTag(SignalLoginTestTags.VIEW_DETAILS_RESET_RECOVERY_KEY_BUTTON).assertIsDisplayed()
}
@Test
fun `when the account key copy button is clicked, CopyAccountIdClicked is emitted`() {
setContent()
@@ -6,14 +6,17 @@
package org.signal.signallogin.viewdetails
import androidx.compose.foundation.Image
import androidx.compose.foundation.ScrollState
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.fillMaxHeight
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
@@ -23,6 +26,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
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
@@ -31,30 +35,87 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.BreakpointPreviews
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.signallogin.R
import org.signal.signallogin.SignalLoginTestTags
import org.signal.signallogin.beta.SignalLoginBetaDisclaimer
import org.signal.signallogin.beta.SignalLoginBetaTag
import org.signal.signallogin.details.SignalLoginKeyDetails
import org.signal.core.ui.R as CoreUiR
/** Size of the miniature credential card artwork shown at the top of the screen, from the design. */
private val MINI_CARD_WIDTH = 175.dp
private val MINI_CARD_HEIGHT = 100.dp
/** Aspect ratio of the credential card artwork. */
private const val CARD_ASPECT_RATIO = 1.75f
/** Corner radius of the card artwork (26dp in its 363dp-wide coordinates), scaled down to the miniature size. */
private val MINI_CARD_CORNER_RADIUS = 13.dp
/** The artwork's 26dp corner radius as a fraction of its 363dp design width, so the radius tracks the card's size. */
private const val CARD_CORNER_RADIUS_RATIO = 26f / 363f
private val BUTTON_MAX_WIDTH = 331.dp
private val smallLayout = Layout.OnePane(
cardWidth = 175.dp,
maxButtonWidth = 331.dp
)
private val mediumLayout = Layout.TwoPane(
cardWidth = 240.dp,
maxButtonWidth = 331.dp,
paneOuterInset = 24.dp,
paneInnerInset = 24.dp,
paneVerticalInset = 24.dp
)
private val largeWidthLayout = Layout.TwoPane(
cardWidth = 280.dp,
maxButtonWidth = 412.dp,
paneOuterInset = 128.dp,
paneInnerInset = 64.dp,
paneVerticalInset = 64.dp
)
private val largeHeightLayout = Layout.OnePane(
cardWidth = 240.dp,
maxButtonWidth = 331.dp
)
/**
* Sizing that the screen switches between as the window grows, mirroring the registration flow's scaffold params.
*/
private sealed interface Layout {
val cardWidth: Dp
val maxButtonWidth: Dp
data class OnePane(
override val cardWidth: Dp,
override val maxButtonWidth: Dp
) : Layout
data class TwoPane(
override val cardWidth: Dp,
override val maxButtonWidth: Dp,
val paneOuterInset: Dp,
val paneInnerInset: Dp,
val paneVerticalInset: Dp
) : Layout
}
@Composable
private fun rememberLayout(): Layout {
return when (val breakpoint = rememberWindowBreakpoint()) {
is WindowBreakpoint.Small -> smallLayout
is WindowBreakpoint.Medium -> mediumLayout
is WindowBreakpoint.Large -> if (breakpoint.isWidthExpanded) largeWidthLayout else largeHeightLayout
}
}
/**
* Shows the user the full keys that make up their Signal Login and offers ways to save them.
@@ -65,50 +126,122 @@ fun SignalLoginViewDetailsScreen(
onEvent: (SignalLoginViewDetailsScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
val layout = rememberLayout()
val firstPaneScrollState = rememberScrollState()
val secondPaneScrollState = rememberScrollState()
Scaffolds.Settings(
title = stringResource(R.string.SignalLoginViewDetailsScreen__signal_login),
onNavigationClick = { onEvent(SignalLoginViewDetailsScreenEvents.BackClicked) },
navigationIcon = SignalIcons.ArrowStart.imageVector,
navigationContentDescription = stringResource(R.string.SignalLoginViewDetailsScreen__navigate_back),
titleContent = { _, title -> TitleWithBetaTag(title) },
modifier = modifier.testTag(SignalLoginTestTags.VIEW_DETAILS_SCREEN)
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
) {
SignalLoginBetaDisclaimer(
modifier = Modifier.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter), vertical = 12.dp)
)
MiniCard(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = 8.dp)
)
Spacer(modifier = Modifier.height(16.dp))
SignalLoginKeyDetails(
accountId = state.accountKey,
recoveryKeyGroups = state.recoveryKeyGroups,
onCopyAccountId = { onEvent(SignalLoginViewDetailsScreenEvents.CopyAccountIdClicked(it)) },
onCopyRecoveryKey = { onEvent(SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked(it)) }
)
}
bottomBar = {
Footer(
maxButtonWidth = layout.maxButtonWidth,
isElevated = firstPaneScrollState.canScrollForward || secondPaneScrollState.canScrollForward,
showResetRecoveryKeyButton = state.showResetRecoveryKeyButton,
resetRecoveryKeyButtonLoading = state.resetRecoveryKeyButtonLoading,
onEvent = onEvent
)
},
modifier = modifier.testTag(SignalLoginTestTags.VIEW_DETAILS_SCREEN)
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
when (layout) {
is Layout.OnePane -> OnePaneContent(
layout = layout,
scrollState = firstPaneScrollState,
state = state,
onEvent = onEvent
)
is Layout.TwoPane -> TwoPaneContent(
layout = layout,
firstPaneScrollState = firstPaneScrollState,
secondPaneScrollState = secondPaneScrollState,
state = state,
onEvent = onEvent
)
}
}
}
}
@Composable
private fun OnePaneContent(
layout: Layout.OnePane,
scrollState: ScrollState,
state: SignalLoginViewDetailsState,
onEvent: (SignalLoginViewDetailsScreenEvents) -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
) {
SignalLoginBetaDisclaimer(
modifier = Modifier
.horizontalGutters()
.padding(vertical = 12.dp)
)
CredentialCard(
width = layout.cardWidth,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(top = 8.dp)
)
Spacer(modifier = Modifier.height(16.dp))
KeyDetails(state = state, onEvent = onEvent)
}
}
@Composable
private fun TwoPaneContent(
layout: Layout.TwoPane,
firstPaneScrollState: ScrollState,
secondPaneScrollState: ScrollState,
state: SignalLoginViewDetailsState,
onEvent: (SignalLoginViewDetailsScreenEvents) -> Unit
) {
Row(modifier = Modifier.fillMaxSize()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.verticalScroll(firstPaneScrollState)
.padding(
start = layout.paneOuterInset,
end = layout.paneInnerInset,
top = layout.paneVerticalInset,
bottom = layout.paneVerticalInset
)
) {
CredentialCard(width = layout.cardWidth)
Spacer(modifier = Modifier.height(24.dp))
SignalLoginBetaDisclaimer(textAlign = TextAlign.Center)
}
Column(
verticalArrangement = Arrangement.Center,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.verticalScroll(secondPaneScrollState)
.padding(vertical = layout.paneVerticalInset)
) {
KeyDetails(state = state, onEvent = onEvent)
}
}
}
@@ -130,90 +263,109 @@ private fun TitleWithBetaTag(title: String) {
}
/**
* A miniature of the credential card artwork, without any of the card's content.
* The credential card artwork, without any of the card's content.
*/
@Composable
private fun MiniCard(modifier: Modifier = Modifier) {
private fun CredentialCard(width: Dp, modifier: Modifier = Modifier) {
Image(
painter = painterResource(R.drawable.image_signal_login_card),
contentDescription = null,
contentScale = ContentScale.FillBounds,
modifier = modifier
.size(width = MINI_CARD_WIDTH, height = MINI_CARD_HEIGHT)
.shadow(elevation = 6.dp, shape = RoundedCornerShape(MINI_CARD_CORNER_RADIUS))
.size(width = width, height = width / CARD_ASPECT_RATIO)
.shadow(elevation = 6.dp, shape = RoundedCornerShape(width * CARD_CORNER_RADIUS_RATIO))
)
}
@Composable
private fun KeyDetails(
state: SignalLoginViewDetailsState,
onEvent: (SignalLoginViewDetailsScreenEvents) -> Unit
) {
SignalLoginKeyDetails(
accountId = state.accountKey,
recoveryKeyGroups = state.recoveryKeyGroups,
onCopyAccountId = { onEvent(SignalLoginViewDetailsScreenEvents.CopyAccountIdClicked(it)) },
onCopyRecoveryKey = { onEvent(SignalLoginViewDetailsScreenEvents.CopyRecoveryKeyClicked(it)) }
)
}
@Composable
private fun Footer(
maxButtonWidth: Dp,
isElevated: Boolean,
showResetRecoveryKeyButton: Boolean,
resetRecoveryKeyButtonLoading: Boolean,
onEvent: (SignalLoginViewDetailsScreenEvents) -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp)
) {
Buttons.MediumTonal(
onClick = { onEvent(SignalLoginViewDetailsScreenEvents.SaveToPasswordManagerClicked) },
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
Surface(shadowElevation = if (isElevated) 8.dp else 0.dp) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.widthIn(max = BUTTON_MAX_WIDTH)
.fillMaxWidth()
.testTag(SignalLoginTestTags.VIEW_DETAILS_SAVE_TO_PASSWORD_MANAGER_BUTTON)
.navigationBarsPadding()
.horizontalGutters()
.padding(vertical = 16.dp)
) {
Text(stringResource(R.string.SignalLoginViewDetailsScreen__save_to_password_manager))
}
Buttons.MediumTonal(
onClick = { onEvent(SignalLoginViewDetailsScreenEvents.SaveToPasswordManagerClicked) },
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
modifier = Modifier
.widthIn(max = maxButtonWidth)
.fillMaxWidth()
.testTag(SignalLoginTestTags.VIEW_DETAILS_SAVE_TO_PASSWORD_MANAGER_BUTTON)
) {
Text(stringResource(R.string.SignalLoginViewDetailsScreen__save_to_password_manager))
}
Buttons.MediumTonal(
onClick = { onEvent(SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked) },
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
modifier = Modifier
.widthIn(max = BUTTON_MAX_WIDTH)
.fillMaxWidth()
.testTag(SignalLoginTestTags.VIEW_DETAILS_SAVE_AS_PDF_BUTTON)
) {
Text(stringResource(R.string.SignalLoginViewDetailsScreen__save_as_pdf))
}
Buttons.MediumTonal(
onClick = { onEvent(SignalLoginViewDetailsScreenEvents.SaveAsPdfClicked) },
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
modifier = Modifier
.widthIn(max = maxButtonWidth)
.fillMaxWidth()
.testTag(SignalLoginTestTags.VIEW_DETAILS_SAVE_AS_PDF_BUTTON)
) {
Text(stringResource(R.string.SignalLoginViewDetailsScreen__save_as_pdf))
}
if (showResetRecoveryKeyButton) {
if (resetRecoveryKeyButtonLoading) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.height(ButtonDefaults.MinHeight)
.testTag(SignalLoginTestTags.VIEW_DETAILS_RESET_RECOVERY_KEY_SPINNER)
) {
CircularProgressIndicator(
strokeWidth = 3.dp,
modifier = Modifier.size(24.dp)
)
}
} else {
TextButton(
onClick = { onEvent(SignalLoginViewDetailsScreenEvents.ResetRecoveryKeyClicked) },
modifier = Modifier
.widthIn(max = BUTTON_MAX_WIDTH)
.fillMaxWidth()
.testTag(SignalLoginTestTags.VIEW_DETAILS_RESET_RECOVERY_KEY_BUTTON)
) {
Text(stringResource(R.string.SignalLoginViewDetailsScreen__reset_recovery_key))
if (showResetRecoveryKeyButton) {
if (resetRecoveryKeyButtonLoading) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.height(ButtonDefaults.MinHeight)
.testTag(SignalLoginTestTags.VIEW_DETAILS_RESET_RECOVERY_KEY_SPINNER)
) {
CircularProgressIndicator(
strokeWidth = 3.dp,
modifier = Modifier.size(24.dp)
)
}
} else {
TextButton(
onClick = { onEvent(SignalLoginViewDetailsScreenEvents.ResetRecoveryKeyClicked) },
modifier = Modifier
.widthIn(max = maxButtonWidth)
.fillMaxWidth()
.testTag(SignalLoginTestTags.VIEW_DETAILS_RESET_RECOVERY_KEY_BUTTON)
) {
Text(stringResource(R.string.SignalLoginViewDetailsScreen__reset_recovery_key))
}
}
}
}
}
}
@DayNightPreviews
@BreakpointPreviews
@Composable
private fun SignalLoginViewDetailsScreenPreview() {
Previews.Preview {
@@ -227,7 +379,7 @@ private fun SignalLoginViewDetailsScreenPreview() {
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun SignalLoginViewDetailsScreenWithResetPreview() {
Previews.Preview {
@@ -242,7 +394,7 @@ private fun SignalLoginViewDetailsScreenWithResetPreview() {
}
}
@DayNightPreviews
@AllDevicePreviews
@Composable
private fun SignalLoginViewDetailsScreenWithResetLoadingPreview() {
Previews.Preview {