Adaptive welcome screen with compact, medium, and large layouts.

This commit is contained in:
Alex Hart
2026-04-08 14:49:56 -03:00
committed by Greyson Parrelli
parent 38baf17938
commit d90e9919ae
10 changed files with 483 additions and 65 deletions
@@ -6,6 +6,10 @@
package org.signal.core.ui
import android.content.res.Resources
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowSizeClass
@@ -31,6 +35,16 @@ fun Resources.getWindowSizeClass(): WindowSizeClass {
)
}
@Composable
fun rememberWindowBreakpoint(): WindowBreakpoint {
val resources = LocalResources.current
val configuration = LocalConfiguration.current
return remember(resources, configuration) {
resources.getWindowBreakpoint()
}
}
/**
* Determines the device's form factor (PHONE, FOLDABLE, or TABLET) based on the current
* [Resources] and window size class.
@@ -39,7 +53,7 @@ fun Resources.getWindowSizeClass(): WindowSizeClass {
* - Returns [WindowBreakpoint.SMALL] if the width or height is compact.
* - Returns [WindowBreakpoint.LARGE] if the height is at least the expanded lower bound.
* - Returns [WindowBreakpoint.MEDIUM] if the width is at least the medium lower bound.
* - Otherwise, falls back to aspect ratio heuristics: wider (≥ 1.6) is [WindowBreakpoint.LARGE], else [WindowBreakpoint.MEDIUM].
* - Otherwise, falls back to aspect ratio heuristics: wider (≥ 1.5) is [WindowBreakpoint.LARGE], else [WindowBreakpoint.MEDIUM].
*
* @return the inferred [WindowBreakpoint] for the current device.
*/
@@ -58,7 +72,7 @@ fun Resources.getWindowBreakpoint(): WindowBreakpoint {
val denominator = minOf(displayMetrics.widthPixels, displayMetrics.heightPixels)
val aspectRatio = numerator.toFloat() / denominator
return if (aspectRatio >= 1.6f) {
return if (aspectRatio >= 1.5f) {
WindowBreakpoint.LARGE
} else {
WindowBreakpoint.MEDIUM
@@ -0,0 +1,74 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
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
/**
* Lays out [primary] and [secondary] side by side in a row. [primaryProportion] controls what
* fraction of the total width [primary] receives (e.g. 0.5f = equal split).
*/
@Composable
fun SideBySideLayout(
primary: @Composable () -> Unit,
secondary: @Composable () -> Unit,
modifier: Modifier = Modifier,
primaryProportion: Float = 0.5f
) {
SubcomposeLayout(modifier = modifier) { constraints ->
val primaryWidth = (constraints.maxWidth * primaryProportion).toInt()
val secondaryWidth = constraints.maxWidth - primaryWidth
val primaryPlaceables = subcompose("primary", primary).map { m ->
m.measure(constraints.copy(minWidth = primaryWidth, maxWidth = primaryWidth, minHeight = 0))
}
val primaryHeight = primaryPlaceables.maxOfOrNull { it.height } ?: 0
val secondaryMeasurables = subcompose("secondary", secondary)
val secondaryMinHeight = secondaryMeasurables.maxOfOrNull { it.minIntrinsicHeight(secondaryWidth) } ?: 0
val layoutHeight = maxOf(primaryHeight, secondaryMinHeight)
val secondaryPlaceables = secondaryMeasurables.map { m ->
m.measure(constraints.copy(minWidth = secondaryWidth, maxWidth = secondaryWidth, minHeight = layoutHeight, maxHeight = layoutHeight))
}
layout(constraints.maxWidth, layoutHeight) {
primaryPlaceables.forEach { it.placeRelative(0, 0) }
secondaryPlaceables.forEach { it.placeRelative(primaryWidth, 0) }
}
}
}
@Preview
@Composable
private fun SideBySideLayoutPreview() {
Previews.Preview {
SideBySideLayout(
primary = {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = Color.Red)
)
},
secondary = {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = Color.Blue)
)
},
modifier = Modifier.fillMaxSize()
)
}
}
@@ -100,10 +100,10 @@ annotation class TabletLandscapeDayPreview
@TabletLandscapeDayPreview
annotation class TabletDayPreviews
@Preview(name = "tablet portrait (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=1280dp,height=840dp,orientation=portrait")
@Preview(name = "tablet portrait (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=840dp,height=1280dp,orientation=portrait")
annotation class TabletPortraitNightPreview
@Preview(name = "tablet landscape (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=840dp,height=1280dp,orientation=landscape")
@Preview(name = "tablet landscape (night)", uiMode = Configuration.UI_MODE_NIGHT_YES, device = "spec:width=1280dp,height=840dp,orientation=landscape")
annotation class TabletLandscapeNightPreview
@TabletPortraitNightPreview
@@ -249,6 +249,7 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
onEvent = { event ->
when (event) {
WelcomeScreenEvents.Continue -> parentEventEmitter.navigateTo(RegistrationRoute.Permissions(nextRoute = RegistrationRoute.PhoneNumberEntry))
WelcomeScreenEvents.LinkDevice -> parentEventEmitter.navigateTo(RegistrationRoute.Permissions(nextRoute = RegistrationRoute.QuickRestoreQrScan)) // TODO - Replace this with the device-link QR code
WelcomeScreenEvents.HasOldPhone -> parentEventEmitter.navigateTo(RegistrationRoute.Permissions(nextRoute = RegistrationRoute.QuickRestoreQrScan))
WelcomeScreenEvents.DoesNotHaveOldPhone -> parentEventEmitter.navigateTo(RegistrationRoute.Permissions(nextRoute = RegistrationRoute.ChooseRestoreOptionBeforeRegistration))
}
@@ -0,0 +1,72 @@
/*
* 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,
footer: (@Composable () -> Unit)? = null
) {
SubcomposeLayout(modifier = modifier.imePadding()) { constraints ->
val footerPlaceables = footer?.let {
subcompose("footer", it).map { m -> m.measure(constraints.copy(minHeight = 0)) }
} ?: emptyList()
val footerHeight = footerPlaceables.maxOfOrNull { it.height } ?: 0
val contentHeight = (constraints.maxHeight - footerHeight).coerceAtLeast(0)
val contentPlaceables = subcompose("content", content).map { m ->
m.measure(constraints.copy(minHeight = contentHeight, maxHeight = contentHeight))
}
layout(constraints.maxWidth, constraints.maxHeight) {
contentPlaceables.forEach { it.placeRelative(0, 0) }
footerPlaceables.forEach { it.placeRelative(0, contentHeight) }
}
}
}
@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()
)
}
}
@@ -10,7 +10,9 @@ package org.signal.registration.screens.welcome
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -18,6 +20,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -41,18 +44,23 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SideBySideLayout
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.dismissWithAnimation
import org.signal.core.ui.compose.horizontalGutters
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.test.TestTags
/**
@@ -65,74 +73,37 @@ fun WelcomeScreen(
modifier: Modifier = Modifier
) {
var showBottomSheet by remember { mutableStateOf(false) }
val windowBreakpoint = rememberWindowBreakpoint()
val onRestoreOrTransferClick = { showBottomSheet = true }
val onTermsAndPrivacyClick: () -> Unit = {}
Column(
modifier = modifier
.fillMaxSize()
.testTag(TestTags.WELCOME_SCREEN),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(R.drawable.welcome),
contentDescription = null,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(16.dp),
contentScale = ContentScale.Fit
)
Text(
text = stringResource(R.string.RegistrationActivity_take_privacy_with_you_be_yourself_in_every_message),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
modifier = Modifier
.padding(horizontal = 32.dp)
.testTag(TestTags.WELCOME_HEADLINE)
)
Spacer(modifier = Modifier.height(40.dp))
TextButton(
onClick = { /* Terms & Privacy link */ },
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
Text(
text = stringResource(R.string.RegistrationActivity_terms_and_privacy),
textAlign = TextAlign.Center
when (windowBreakpoint) {
WindowBreakpoint.SMALL -> {
CompactLayout(
onEvent = onEvent,
onRestoreOrTransferClick = onRestoreOrTransferClick,
onTermsAndPrivacyClick = onTermsAndPrivacyClick,
modifier = modifier
)
}
Spacer(modifier = Modifier.height(24.dp))
Buttons.LargeTonal(
onClick = { onEvent(WelcomeScreenEvents.Continue) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.testTag(TestTags.WELCOME_GET_STARTED_BUTTON)
) {
Text(stringResource(R.string.RegistrationActivity_continue))
WindowBreakpoint.MEDIUM -> {
MediumLayout(
onEvent = onEvent,
onRestoreOrTransferClick = onRestoreOrTransferClick,
onTermsAndPrivacyClick = onTermsAndPrivacyClick,
modifier = modifier
)
}
Spacer(modifier = Modifier.height(17.dp))
Buttons.LargeTonal(
onClick = { showBottomSheet = true },
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = SignalTheme.colors.colorSurface2
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.testTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON)
) {
Text(stringResource(R.string.registration_activity__restore_or_transfer))
WindowBreakpoint.LARGE -> {
LargeLayout(
onEvent = onEvent,
onTermsAndPrivacyClick = onTermsAndPrivacyClick,
onRestoreOrTransferClick = onRestoreOrTransferClick,
modifier = modifier
)
}
Spacer(modifier = Modifier.height(48.dp))
}
if (showBottomSheet) {
@@ -146,6 +117,261 @@ fun WelcomeScreen(
}
}
@Composable
private fun CompactLayout(
onEvent: (WelcomeScreenEvents) -> Unit,
onTermsAndPrivacyClick: () -> Unit,
onRestoreOrTransferClick: () -> Unit,
modifier: Modifier = Modifier
) {
RegistrationScreen(
modifier = modifier
.fillMaxSize()
.testTag(TestTags.WELCOME_SCREEN),
content = {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
HeroImage(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(16.dp)
)
Headline(
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 32.dp)
)
Spacer(modifier = Modifier.height(40.dp))
}
},
footer = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
TermsAndPrivacy(onTermsAndPrivacyClick = onTermsAndPrivacyClick)
Spacer(modifier = Modifier.height(24.dp))
PrimaryDeviceCallToActionButtons(
onEvent = onEvent,
onRestoreOrTransferClick = onRestoreOrTransferClick
)
Spacer(modifier = Modifier.height(48.dp))
}
}
)
}
@Composable
private fun MediumLayout(
onEvent: (WelcomeScreenEvents) -> Unit,
onTermsAndPrivacyClick: () -> Unit,
onRestoreOrTransferClick: () -> Unit,
modifier: Modifier = Modifier
) {
RegistrationScreen(
modifier = modifier
.fillMaxSize()
.padding(bottom = 56.dp),
content = {
SideBySideLayout(
modifier = Modifier.fillMaxSize(),
primary = {
HeroImage()
},
secondary = {
Headline(
modifier = Modifier.padding(top = 88.dp)
)
}
)
},
footer = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
TermsAndPrivacy(onTermsAndPrivacyClick = onTermsAndPrivacyClick)
PrimaryDeviceCallToActionButtons(
onEvent = onEvent,
onRestoreOrTransferClick = onRestoreOrTransferClick
)
}
}
)
}
@Composable
private fun LargeLayout(
onEvent: (WelcomeScreenEvents) -> Unit,
onTermsAndPrivacyClick: () -> Unit,
onRestoreOrTransferClick: () -> Unit,
modifier: Modifier = Modifier
) {
RegistrationScreen(
modifier = modifier.fillMaxSize(),
content = {
SideBySideLayout(
modifier = Modifier.fillMaxSize(),
primary = {
HeroImage(
modifier = Modifier.padding(vertical = 10.dp)
)
},
secondary = {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth()
) {
Column(
horizontalAlignment = Alignment.Start,
modifier = Modifier
.widthIn(max = 380.dp)
.fillMaxWidth()
.padding(top = 98.dp)
) {
Headline(
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(start = 10.dp)
)
Spacer(modifier = Modifier.weight(1f))
TermsAndPrivacy(
onTermsAndPrivacyClick = onTermsAndPrivacyClick,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(bottom = 8.dp)
)
PrimaryDeviceCallToActionButtons(
onEvent = onEvent,
onRestoreOrTransferClick = onRestoreOrTransferClick
)
}
}
}
)
}
)
}
@Composable
private fun HeroImage(
modifier: Modifier = Modifier
) {
Image(
painter = painterResource(R.drawable.welcome),
contentDescription = null,
modifier = modifier,
contentScale = ContentScale.Fit
)
}
@Composable
private fun Headline(
modifier: Modifier = Modifier,
style: TextStyle = MaterialTheme.typography.headlineMedium,
textAlign: TextAlign = TextAlign.Start
) {
Text(
text = stringResource(R.string.RegistrationActivity_take_privacy_with_you_be_yourself_in_every_message),
style = style,
textAlign = textAlign,
modifier = modifier
.testTag(TestTags.WELCOME_HEADLINE)
)
}
@Composable
private fun TermsAndPrivacy(
onTermsAndPrivacyClick: () -> Unit,
modifier: Modifier = Modifier
) {
TextButton(
onClick = onTermsAndPrivacyClick,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
),
modifier = modifier
) {
Text(
text = stringResource(R.string.RegistrationActivity_terms_and_privacy),
textAlign = TextAlign.Center
)
}
}
@Composable
private fun ColumnScope.PrimaryDeviceCallToActionButtons(
onEvent: (WelcomeScreenEvents) -> Unit,
onRestoreOrTransferClick: () -> Unit
) {
Buttons.LargeTonal(
onClick = { onEvent(WelcomeScreenEvents.Continue) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.testTag(TestTags.WELCOME_GET_STARTED_BUTTON)
) {
Text(stringResource(R.string.RegistrationActivity_continue))
}
Spacer(modifier = Modifier.height(17.dp))
Buttons.LargeTonal(
onClick = onRestoreOrTransferClick,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = SignalTheme.colors.colorSurface2
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.testTag(TestTags.WELCOME_RESTORE_OR_TRANSFER_BUTTON)
) {
Text(stringResource(R.string.registration_activity__restore_or_transfer))
}
}
@Composable
private fun ColumnScope.SecondaryDeviceCallToActionButtons(
onEvent: (WelcomeScreenEvents) -> Unit
) {
Buttons.LargeTonal(
onClick = { onEvent(WelcomeScreenEvents.LinkDevice) },
modifier = Modifier
.fillMaxWidth()
.testTag(TestTags.WELCOME_LINK_DEVICE_BUTTON)
) {
Text(stringResource(R.string.WelcomeScreen__link_your_account))
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Text(
text = stringResource(R.string.WelcomeScreen__not_on_signal_yet)
)
TextButton(
onClick = { onEvent(WelcomeScreenEvents.Continue) },
modifier = Modifier.testTag(TestTags.WELCOME_GET_STARTED_BUTTON)
) {
Text(
text = stringResource(R.string.WelcomeScreen__create_account)
)
}
}
}
/**
* Bottom sheet for restore or transfer options.
*/
@@ -9,6 +9,7 @@ import org.signal.registration.util.DebugLoggableModel
sealed class WelcomeScreenEvents : DebugLoggableModel() {
data object Continue : WelcomeScreenEvents()
data object LinkDevice : WelcomeScreenEvents()
data object HasOldPhone : WelcomeScreenEvents()
data object DoesNotHaveOldPhone : WelcomeScreenEvents()
}
@@ -14,6 +14,7 @@ object TestTags {
const val WELCOME_SCREEN = "welcome_screen"
const val WELCOME_HEADLINE = "welcome_headline"
const val WELCOME_GET_STARTED_BUTTON = "welcome_get_started_button"
const val WELCOME_LINK_DEVICE_BUTTON = "welcome_link_device_button"
const val WELCOME_RESTORE_OR_TRANSFER_BUTTON = "welcome_restore_or_transfer_button"
const val WELCOME_RESTORE_HAS_OLD_PHONE_BUTTON = "welcome_restore_has_old_phone_button"
const val WELCOME_RESTORE_NO_OLD_PHONE_BUTTON = "welcome_restore_no_old_phone_button"
@@ -205,4 +205,10 @@
<string name="LocalBackupRestoreScreen__restore_failed_description">An error occurred while restoring your backup. Please try again.</string>
<!-- Button to retry restore after failure -->
<string name="LocalBackupRestoreScreen__try_again">Try again</string>
<!-- Button to link an existing Signal account to this device -->
<string name="WelcomeScreen__link_your_account">Link your account</string>
<!-- Prompt shown to users who don't have a Signal account yet -->
<string name="WelcomeScreen__not_on_signal_yet">Not on Signal yet?</string>
<!-- Button to create a new Signal account -->
<string name="WelcomeScreen__create_account">Create account</string>
</resources>
@@ -56,6 +56,29 @@ class WelcomeScreenTest {
assert(emittedEvent == WelcomeScreenEvents.Continue)
}
@Config(qualifiers = "w1280dp-h800dp-xhdpi")
@Test
fun `when Link Your Account is clicked, LinkDevice event is emitted`() {
// Given
var emittedEvent: WelcomeScreenEvents? = null
composeTestRule.setContent {
SignalTheme {
WelcomeScreen(
onEvent = { event ->
emittedEvent = event
}
)
}
}
// When
composeTestRule.onNodeWithTag(TestTags.WELCOME_GET_STARTED_BUTTON).performClick()
// Then
assert(emittedEvent == WelcomeScreenEvents.Continue)
}
@Test
fun `when Restore or transfer is clicked, bottom sheet is shown`() {
// Given