From 2479df6d8b4dd7908a57648551121766ffcf0a32 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 21 Sep 2026 11:11:21 -0400 Subject: [PATCH] Add a two-pane login view details view. --- .../SignalLoginViewDetailsScreenTest.kt | 12 + .../SignalLoginViewDetailsScreen.kt | 354 +++++++++++++----- 2 files changed, 265 insertions(+), 101 deletions(-) diff --git a/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsScreenTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsScreenTest.kt index 1c90761ffc..c439a51972 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsScreenTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/signallogindetails/SignalLoginViewDetailsScreenTest.kt @@ -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() diff --git a/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreen.kt b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreen.kt index ad3710ddda..9f857b9d0e 100644 --- a/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreen.kt +++ b/lib/signal-login/src/main/java/org/signal/signallogin/viewdetails/SignalLoginViewDetailsScreen.kt @@ -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 {