mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-28 07:51:42 +01:00
Add a two-pane login view details view.
This commit is contained in:
+12
@@ -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()
|
||||
|
||||
+253
-101
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user