Add two pane registration scaffold.

This commit is contained in:
jeffrey-signal
2026-05-14 13:49:00 -04:00
parent 38bac16640
commit 10d969ea35
10 changed files with 334 additions and 111 deletions
@@ -0,0 +1,302 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.rememberWindowBreakpoint
object RegistrationScaffold {
private val smallLayoutParams = Params.OnePane(
topInset = 24.dp,
bottomInset = 24.dp,
paneVerticalInset = 24.dp,
paneHorizontalInset = 24.dp,
maxButtonWidth = 320.dp
)
private val mediumLayoutParams = Params.TwoPane(
topInset = 64.dp,
bottomInset = 24.dp,
paneTopInset = 16.dp,
paneBottomInset = 24.dp,
paneOuterInset = 24.dp,
paneInnerInset = 24.dp,
maxButtonWidth = 320.dp
)
private val largeWidthLayoutParams = Params.TwoPane(
topInset = 64.dp,
bottomInset = 32.dp,
paneTopInset = 64.dp,
paneBottomInset = 64.dp,
paneOuterInset = 128.dp,
paneInnerInset = 64.dp,
maxButtonWidth = 412.dp
)
private val largeHeightLayoutParams = Params.OnePane(
topInset = 64.dp,
bottomInset = 32.dp,
paneVerticalInset = 64.dp,
paneHorizontalInset = 128.dp,
maxButtonWidth = 320.dp
)
sealed interface Params {
val topInset: Dp
val bottomInset: Dp
val maxButtonWidth: Dp
data class OnePane(
override val topInset: Dp,
private val paneVerticalInset: Dp,
private val paneHorizontalInset: Dp,
override val bottomInset: Dp,
override val maxButtonWidth: Dp
) : Params {
val panePadding = PaddingValues(
top = topInset + paneVerticalInset,
bottom = paneVerticalInset,
start = paneHorizontalInset,
end = paneHorizontalInset
)
}
data class TwoPane(
override val topInset: Dp,
private val paneTopInset: Dp,
private val paneBottomInset: Dp,
private val paneOuterInset: Dp,
private val paneInnerInset: Dp,
override val bottomInset: Dp,
override val maxButtonWidth: Dp
) : Params {
val firstPanePadding: PaddingValues = PaddingValues(
top = topInset + paneTopInset,
bottom = paneBottomInset,
start = paneOuterInset,
end = paneInnerInset
)
val secondPanePadding: PaddingValues = PaddingValues(
top = topInset + paneTopInset,
bottom = paneBottomInset,
start = paneInnerInset,
end = paneOuterInset
)
}
}
@Composable
fun rememberLayoutParams(): Params {
return when (rememberWindowBreakpoint()) {
WindowBreakpoint.SMALL -> smallLayoutParams
WindowBreakpoint.MEDIUM -> mediumLayoutParams
WindowBreakpoint.LARGE_WIDTH -> largeWidthLayoutParams
WindowBreakpoint.LARGE_HEIGHT -> largeHeightLayoutParams
}
}
}
/**
* Scaffold for registration flow screens.
*/
@Composable
fun RegistrationScaffold(
content: @Composable () -> Unit,
modifier: Modifier = Modifier,
header: (@Composable () -> Unit)? = null,
footer: (@Composable () -> Unit)? = null
) {
SubcomposeLayout(modifier = modifier.imePadding()) { constraints ->
val footerPlaceables = footer?.let {
subcompose("footer", it).map { m -> m.measure(constraints.copy(minWidth = 0, minHeight = 0)) }
} ?: emptyList()
val footerHeight = footerPlaceables.maxOfOrNull { it.height } ?: 0
val headerPlaceables = header?.let {
subcompose("header", it).map { m -> m.measure(constraints.copy(minWidth = 0, minHeight = 0)) }
} ?: emptyList()
val headerHeight = headerPlaceables.maxOfOrNull { it.height } ?: 0
val contentHeight = (constraints.maxHeight - footerHeight - headerHeight).coerceAtLeast(0)
val contentPlaceables = subcompose("content", content).map { m ->
m.measure(constraints.copy(minHeight = contentHeight, maxHeight = contentHeight))
}
layout(constraints.maxWidth, constraints.maxHeight) {
headerPlaceables.forEach { it.placeRelative(0, 0) }
contentPlaceables.forEach { it.placeRelative(0, headerHeight) }
footerPlaceables.forEach { it.placeRelative(0, contentHeight + headerHeight) }
}
}
}
/**
* Two-pane variant of [RegistrationScaffold] for medium and large-width breakpoints.
*/
@Composable
fun TwoPaneRegistrationScaffold(
modifier: Modifier = Modifier,
params: RegistrationScaffold.Params.TwoPane,
header: (@Composable () -> Unit)? = null,
footer: (@Composable () -> Unit)? = null,
firstPane: @Composable RowScope.(PaddingValues) -> Unit,
secondPane: @Composable RowScope.(PaddingValues) -> Unit
) {
RegistrationScaffold(
modifier = modifier.fillMaxSize(),
header = header,
footer = footer,
content = {
Row(
horizontalArrangement = Arrangement.SpaceAround,
modifier = Modifier.fillMaxSize()
) {
firstPane(params.firstPanePadding)
secondPane(params.secondPanePadding)
}
}
)
}
@Composable
private fun PreviewPane(
label: String,
paddingValues: PaddingValues,
outerColor: Color,
innerColor: Color,
modifier: Modifier = Modifier
) {
Text(
modifier = modifier
.fillMaxHeight()
.background(outerColor)
.padding(paddingValues)
.background(innerColor)
.wrapContentHeight(Alignment.CenterVertically),
text = label,
textAlign = TextAlign.Center,
fontSize = 30.sp,
color = Color.Black
)
}
@AllDevicePreviews
@Composable
private fun RegistrationScaffoldPreview() = Previews.Preview {
when (val params = RegistrationScaffold.rememberLayoutParams()) {
is RegistrationScaffold.Params.OnePane -> {
RegistrationScaffold(
header = {
Text(
modifier = Modifier
.fillMaxWidth()
.background(Color.Green)
.padding(16.dp),
text = "header",
textAlign = TextAlign.Center,
fontSize = 24.sp,
color = Color.Black
)
},
content = {
PreviewPane(
label = "content",
paddingValues = params.panePadding,
outerColor = Color.Red,
innerColor = Color.Yellow,
modifier = Modifier.fillMaxWidth()
)
},
footer = {
Text(
modifier = Modifier
.fillMaxWidth()
.background(Color.Green)
.padding(16.dp),
text = "footer",
textAlign = TextAlign.Center,
fontSize = 24.sp,
color = Color.Black
)
}
)
}
is RegistrationScaffold.Params.TwoPane -> {
TwoPaneRegistrationScaffold(
modifier = Modifier.fillMaxSize(),
params = params,
header = {
Text(
modifier = Modifier
.fillMaxWidth()
.background(Color.Green)
.padding(16.dp),
text = "header",
textAlign = TextAlign.Center,
fontSize = 24.sp,
color = Color.Black
)
},
firstPane = { paddingValues ->
PreviewPane(
label = "firstPane",
paddingValues = paddingValues,
outerColor = Color.Red,
innerColor = Color.Yellow,
modifier = Modifier.weight(1f)
)
},
secondPane = { paddingValues ->
PreviewPane(
label = "secondPane",
paddingValues = paddingValues,
outerColor = Color.Blue,
innerColor = Color.Yellow,
modifier = Modifier.weight(1f)
)
},
footer = {
Text(
modifier = Modifier
.fillMaxWidth()
.background(Color.Green)
.padding(16.dp),
text = "footer",
textAlign = TextAlign.Center,
fontSize = 24.sp,
color = Color.Black
)
}
)
}
}
}
@@ -1,79 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Previews
/**
* Scaffold for registration flow screens.
*/
@Composable
fun RegistrationScreen(
content: @Composable () -> Unit,
modifier: Modifier = Modifier,
header: (@Composable () -> Unit)? = null,
footer: (@Composable () -> Unit)? = null
) {
SubcomposeLayout(modifier = modifier.imePadding()) { constraints ->
val footerPlaceables = footer?.let {
subcompose("footer", it).map { m -> m.measure(constraints.copy(minWidth = 0, minHeight = 0)) }
} ?: emptyList()
val footerHeight = footerPlaceables.maxOfOrNull { it.height } ?: 0
val headerPlaceables = header?.let {
subcompose("header", it).map { m -> m.measure(constraints.copy(minWidth = 0, minHeight = 0)) }
} ?: emptyList()
val headerHeight = headerPlaceables.maxOfOrNull { it.height } ?: 0
val contentHeight = (constraints.maxHeight - footerHeight - headerHeight).coerceAtLeast(0)
val contentPlaceables = subcompose("content", content).map { m ->
m.measure(constraints.copy(minHeight = contentHeight, maxHeight = contentHeight))
}
layout(constraints.maxWidth, constraints.maxHeight) {
headerPlaceables.forEach { it.placeRelative(0, 0) }
contentPlaceables.forEach { it.placeRelative(0, headerHeight) }
footerPlaceables.forEach { it.placeRelative(0, contentHeight + headerHeight) }
}
}
}
@Preview
@Composable
private fun RegistrationScreenPreview() {
Previews.Preview {
RegistrationScreen(
content = {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = Color.Red)
)
},
footer = {
Box(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
.background(color = Color.Green)
)
},
modifier = Modifier.fillMaxSize()
)
}
}
@@ -53,7 +53,7 @@ import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScreen
import org.signal.registration.screens.RegistrationScaffold
import org.signal.registration.screens.localbackuprestore.attachBackupKeyAutoFillHelper
import org.signal.registration.screens.localbackuprestore.backupKeyAutoFillHelper
@@ -83,7 +83,7 @@ fun EnterAepScreen(
@Composable
private fun CompactLayout(state: EnterAepState, onEvent: (EnterAepEvents) -> Unit) {
val scrollState = rememberScrollState()
RegistrationScreen(
RegistrationScaffold(
modifier = Modifier.fillMaxSize(),
content = {
Column(
@@ -124,7 +124,7 @@ private fun CompactLayout(state: EnterAepState, onEvent: (EnterAepEvents) -> Uni
@Composable
private fun MediumLayout(state: EnterAepState, onEvent: (EnterAepEvents) -> Unit) {
val scrollState = rememberScrollState()
RegistrationScreen(
RegistrationScaffold(
modifier = Modifier.fillMaxSize(),
content = {
Row(
@@ -162,7 +162,7 @@ private fun MediumLayout(state: EnterAepState, onEvent: (EnterAepEvents) -> Unit
@Composable
private fun LargeLayout(state: EnterAepState, onEvent: (EnterAepEvents) -> Unit) {
val scrollState = rememberScrollState()
RegistrationScreen(
RegistrationScaffold(
modifier = Modifier.fillMaxSize(),
content = {
Row(
@@ -59,7 +59,7 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScreen
import org.signal.registration.screens.RegistrationScaffold
/**
* Screen that allows someone to search and select a country code from a supported list of countries.
@@ -90,7 +90,7 @@ fun CountryCodePickerScreen(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CompactLayout(state: CountryCodeState, onEvent: (CountryCodePickerScreenEvents) -> Unit) {
RegistrationScreen(
RegistrationScaffold(
modifier = Modifier.fillMaxSize(),
header = {
Row(
@@ -119,7 +119,7 @@ private fun CompactLayout(state: CountryCodeState, onEvent: (CountryCodePickerSc
@Composable
private fun MediumLayout(state: CountryCodeState, onEvent: (CountryCodePickerScreenEvents) -> Unit) {
RegistrationScreen(
RegistrationScaffold(
modifier = Modifier.fillMaxSize(),
header = {
Header(onEvent)
@@ -148,7 +148,7 @@ private fun MediumLayout(state: CountryCodeState, onEvent: (CountryCodePickerScr
@Composable
private fun LargeLayout(state: CountryCodeState, onEvent: (CountryCodePickerScreenEvents) -> Unit) {
RegistrationScreen(
RegistrationScaffold(
modifier = Modifier.fillMaxSize(),
header = {
Header(onEvent)
@@ -43,7 +43,7 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScreen
import org.signal.registration.screens.RegistrationScaffold
import org.signal.registration.screens.util.MockMultiplePermissionsState
import org.signal.registration.screens.util.MockPermissionsState
import org.signal.registration.test.TestTags
@@ -108,7 +108,7 @@ private fun CompactLayout(
permissionsState: MultiplePermissionsState,
onProceed: () -> Unit
) {
RegistrationScreen(
RegistrationScaffold(
modifier = modifier.fillMaxSize(),
content = {
Box(
@@ -160,7 +160,7 @@ private fun MediumLayout(
) {
val scrollState = rememberScrollState()
RegistrationScreen(
RegistrationScaffold(
modifier = modifier.fillMaxSize(),
content = {
Row(
@@ -213,7 +213,7 @@ private fun LargeLayout(
) {
val scrollState = rememberScrollState()
RegistrationScreen(
RegistrationScaffold(
modifier = modifier.fillMaxSize(),
content = {
Row(
@@ -60,7 +60,7 @@ import org.signal.core.ui.compose.IconButtons.IconButton
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScreen
import org.signal.registration.screens.RegistrationScaffold
import org.signal.registration.screens.phonenumber.PhoneNumberEntryState.OneTimeEvent
import org.signal.registration.test.TestTags
@@ -181,7 +181,7 @@ private fun CompactLayout(state: PhoneNumberEntryState, onEvent: (PhoneNumberEnt
val scrollState = rememberScrollState()
RegistrationScreen(
RegistrationScaffold(
modifier = Modifier,
header = {
TopbarMenu()
@@ -234,7 +234,7 @@ private fun MediumLayout(state: PhoneNumberEntryState, onEvent: (PhoneNumberEntr
val scrollState = rememberScrollState()
RegistrationScreen(
RegistrationScaffold(
modifier = Modifier,
header = {
TopbarMenu()
@@ -297,7 +297,7 @@ private fun LargeLayout(state: PhoneNumberEntryState, onEvent: (PhoneNumberEntry
val scrollState = rememberScrollState()
RegistrationScreen(
RegistrationScaffold(
modifier = Modifier,
header = {
TopbarMenu()
@@ -48,7 +48,7 @@ import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScreen
import org.signal.registration.screens.RegistrationScaffold
import java.util.Date
@Composable
@@ -103,7 +103,7 @@ private fun CompactLayout(
onEvent: (RemoteBackupRestoreScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
RegistrationScreen(
RegistrationScaffold(
content = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -131,7 +131,7 @@ private fun MediumLayout(
onEvent: (RemoteBackupRestoreScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
RegistrationScreen(
RegistrationScaffold(
content = {
Box(
contentAlignment = Alignment.Center,
@@ -172,7 +172,7 @@ private fun LargeLayout(
onEvent: (RemoteBackupRestoreScreenEvents) -> Unit,
modifier: Modifier = Modifier
) {
RegistrationScreen(
RegistrationScaffold(
content = {
Row(
modifier = Modifier
@@ -39,7 +39,7 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScreen
import org.signal.registration.screens.RegistrationScaffold
import org.signal.registration.test.TestTags
@Composable
@@ -81,7 +81,7 @@ fun ArchiveRestoreSelectionScreen(
@Composable
private fun CompactLayout(state: ArchiveRestoreSelectionState, onEvent: (ArchiveRestoreSelectionScreenEvents) -> Unit, modifier: Modifier) {
val scrollState = rememberScrollState()
RegistrationScreen(
RegistrationScaffold(
modifier = Modifier.fillMaxSize(),
content = {
Column(
@@ -113,7 +113,7 @@ private fun CompactLayout(state: ArchiveRestoreSelectionState, onEvent: (Archive
@Composable
private fun MediumLayout(state: ArchiveRestoreSelectionState, onEvent: (ArchiveRestoreSelectionScreenEvents) -> Unit, modifier: Modifier) {
val scrollState = rememberScrollState()
RegistrationScreen(
RegistrationScaffold(
modifier = Modifier.fillMaxSize(),
content = {
Row(
@@ -150,7 +150,7 @@ private fun MediumLayout(state: ArchiveRestoreSelectionState, onEvent: (ArchiveR
private fun LargeLayout(state: ArchiveRestoreSelectionState, onEvent: (ArchiveRestoreSelectionScreenEvents) -> Unit, modifier: Modifier) {
val scrollState = rememberScrollState()
RegistrationScreen(
RegistrationScaffold(
modifier = Modifier.fillMaxSize(),
content = {
Row(
@@ -55,7 +55,7 @@ import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScreen
import org.signal.registration.screens.RegistrationScaffold
import org.signal.registration.test.TestTags
import kotlin.time.Duration.Companion.seconds
@@ -177,7 +177,7 @@ private fun CompactLayout(
) {
val scrollState = rememberScrollState()
RegistrationScreen(
RegistrationScaffold(
modifier = Modifier.fillMaxSize(),
content = {
Column(
@@ -231,7 +231,7 @@ private fun MediumLayout(
onDigitsChanged: (List<String>) -> Unit
) {
val scrollState = rememberScrollState()
RegistrationScreen(
RegistrationScaffold(
modifier = Modifier.fillMaxSize(),
content = {
Row(
@@ -290,7 +290,7 @@ private fun LargeLayout(
) {
val scrollState = rememberScrollState()
RegistrationScreen(
RegistrationScaffold(
modifier = Modifier.fillMaxSize(),
content = {
Row(
@@ -64,7 +64,7 @@ import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.isWidthExpanded
import org.signal.core.ui.rememberWindowBreakpoint
import org.signal.registration.R
import org.signal.registration.screens.RegistrationScreen
import org.signal.registration.screens.RegistrationScaffold
import org.signal.registration.test.TestTags
/**
@@ -128,7 +128,7 @@ private fun CompactLayout(
onRestoreOrTransferClick: () -> Unit,
modifier: Modifier = Modifier
) {
RegistrationScreen(
RegistrationScaffold(
modifier = modifier
.fillMaxSize()
.testTag(TestTags.WELCOME_SCREEN),
@@ -186,7 +186,7 @@ private fun MediumLayout(
onRestoreOrTransferClick: () -> Unit,
modifier: Modifier = Modifier
) {
RegistrationScreen(
RegistrationScaffold(
modifier = modifier
.fillMaxSize()
.padding(bottom = 56.dp),
@@ -242,7 +242,7 @@ private fun LargeLayout(
onRestoreOrTransferClick: () -> Unit,
modifier: Modifier = Modifier
) {
RegistrationScreen(
RegistrationScaffold(
modifier = modifier.fillMaxSize(),
content = {
Row(