Coverage Summary for Class: SignInScreenKt (com.georgeci.moneysurfer.feature.login)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| SignInScreenKt |
85.3%
(29/34)
|
48.9%
(68/139)
|
94.4%
(356/377)
|
90.8%
(3319/3656)
|
| SignInScreenKt$WhenMappings |
|
| Total |
85.3%
(29/34)
|
48.9%
(68/139)
|
94.4%
(356/377)
|
90.8%
(3319/3656)
|
package com.georgeci.moneysurfer.feature.login
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
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.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.georgeci.moneysurfer.uikit.components.SurferAppIcon
import com.georgeci.moneysurfer.uikit.components.SurferAuthBackground
import com.georgeci.moneysurfer.uikit.components.SurferAuthGradient
import com.georgeci.moneysurfer.uikit.components.SurferFullScreenLoader
import com.georgeci.moneysurfer.uikit.components.SurferPasswordField
import com.georgeci.moneysurfer.uikit.components.SurferTextField
import com.georgeci.moneysurfer.uikit.icons.SurferIcons
import com.georgeci.moneysurfer.uikit.modifier.SurferWideContentMaxWidth
import com.georgeci.moneysurfer.uikit.modifier.surferContentContainer
import com.georgeci.moneysurfer.uikit.modifier.surferSafeInsets
import com.georgeci.moneysurfer.uikit.modifier.surferTestTagAsId
import com.georgeci.moneysurfer.uikit.semantics.SurferSemantics
import com.georgeci.moneysurfer.uikit.theme.AppTheme
import com.georgeci.moneysurfer.uikit.theme.ConfigureSystemBars
import com.georgeci.moneysurfer.uikit.tokens.AuthColors
import com.georgeci.moneysurfer.uikit.window.SurferWindowGeometry
import com.georgeci.moneysurfer.uikit.window.SurferWindowSize
import com.georgeci.moneysurfer.uikit.window.currentSurferWindowGeometry
import com.georgeci.moneysurfer.utils.HandleSideEffect
import moneysurfer.feature.login.generated.resources.Res
import moneysurfer.feature.login.generated.resources.sign_in_anonymous
import moneysurfer.feature.login.generated.resources.sign_in_brand
import moneysurfer.feature.login.generated.resources.sign_in_demo_mode
import moneysurfer.feature.login.generated.resources.sign_in_email_label
import moneysurfer.feature.login.generated.resources.sign_in_error_dialog_ok
import moneysurfer.feature.login.generated.resources.sign_in_error_dialog_title
import moneysurfer.feature.login.generated.resources.sign_in_error_email_in_use
import moneysurfer.feature.login.generated.resources.sign_in_error_email_invalid
import moneysurfer.feature.login.generated.resources.sign_in_error_email_required
import moneysurfer.feature.login.generated.resources.sign_in_error_invalid_credentials
import moneysurfer.feature.login.generated.resources.sign_in_error_password_required
import moneysurfer.feature.login.generated.resources.sign_in_error_password_too_short
import moneysurfer.feature.login.generated.resources.sign_in_error_permission_denied
import moneysurfer.feature.login.generated.resources.sign_in_error_unknown
import moneysurfer.feature.login.generated.resources.sign_in_error_weak_password
import moneysurfer.feature.login.generated.resources.sign_in_hero_subtitle
import moneysurfer.feature.login.generated.resources.sign_in_hero_title
import moneysurfer.feature.login.generated.resources.sign_in_or
import moneysurfer.feature.login.generated.resources.sign_in_password_label
import moneysurfer.feature.login.generated.resources.sign_in_sheet_subtitle
import moneysurfer.feature.login.generated.resources.sign_in_sheet_title
import moneysurfer.feature.login.generated.resources.sign_in_submit_signin
import moneysurfer.feature.login.generated.resources.sign_in_submit_signup
import moneysurfer.feature.login.generated.resources.sign_in_terms
import moneysurfer.feature.login.generated.resources.sign_in_toggle_to_signin
import moneysurfer.feature.login.generated.resources.sign_in_toggle_to_signup
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
object SignInTestTags {
const val Root = "signIn:root"
const val BrandHeader = "signIn:brandHeader"
const val HeroTitle = "signIn:heroTitle"
const val HeroSubtitle = "signIn:heroSubtitle"
const val Sheet = "signIn:sheet"
const val SheetTitle = "signIn:sheetTitle"
const val SheetSubtitle = "signIn:sheetSubtitle"
const val EmailField = "signIn:email"
const val PasswordField = "signIn:password"
const val SubmitButton = "signIn:submit"
const val ToggleModeButton = "signIn:toggleMode"
const val AnonymousButton = "signIn:anonymous"
const val DemoButton = "signIn:demo"
const val ErrorText = "signIn:error"
const val ErrorDialog = "signIn:errorDialog"
const val ErrorDialogConfirm = "signIn:errorDialog:confirm"
const val EmailError = "$EmailField:error"
const val PasswordError = "$PasswordField:error"
const val PasswordReveal = "$PasswordField:reveal"
const val Loader = "signIn:loader"
const val Terms = "signIn:terms"
}
private val SheetCorner: Dp = 28.dp
private val PrimaryButtonHeight: Dp = 52.dp
private val BrandIconSize: Dp = 42.dp
private val SheetPadding: Dp = 20.dp
private val SheetElevation: Dp = 12.dp
private val SheetTitleSize = 18.sp
private val PasskeyIconSize: Dp = 18.dp
private val OrLabelSize = 11.sp
private val PrimaryLabelSize = 15.sp
/**
* How wide the form is allowed to grow, in both layouts — the stacked column and the split card are
* the same thing at different addresses, so they hold the same measure.
*/
private val ContentMaxWidth: Dp = 480.dp
private val HeroSubtitleMaxWidth: Dp = 320.dp
@Composable
private fun SignInError.localized(): String = stringResource(
when (this) {
SignInError.EmailRequired -> Res.string.sign_in_error_email_required
SignInError.EmailInvalid -> Res.string.sign_in_error_email_invalid
SignInError.PasswordRequired -> Res.string.sign_in_error_password_required
SignInError.PasswordTooShort -> Res.string.sign_in_error_password_too_short
SignInError.InvalidCredentials -> Res.string.sign_in_error_invalid_credentials
SignInError.EmailAlreadyInUse -> Res.string.sign_in_error_email_in_use
SignInError.WeakPassword -> Res.string.sign_in_error_weak_password
SignInError.PermissionDenied -> Res.string.sign_in_error_permission_denied
SignInError.Unknown -> Res.string.sign_in_error_unknown
},
)
@Composable
fun SignInScreen(
onNavigateToWorkspaceSelector: (cloudDataUnavailable: Boolean) -> Unit,
onNavigateToLegal: () -> Unit,
viewModel: SignInViewModel = koinViewModel(),
) {
val state by viewModel.collectAsStateWithLifecycle()
viewModel.HandleSideEffect { effect ->
when (effect) {
is SignInEffect.NavigateToWorkspaceSelector ->
onNavigateToWorkspaceSelector(effect.cloudDataUnavailable)
SignInEffect.NavigateToLegal -> onNavigateToLegal()
}
}
SignInContent(
state = state,
onEvent = viewModel::onEvent,
)
}
/**
* Stateless body of the sign-in screen. Public so screen-state UI tests can mount it with an
* injected [SignInState] instead of standing up a Koin-backed [SignInViewModel].
*/
@Composable
fun SignInContent(
state: SignInState,
onEvent: (SignInEvent) -> Unit,
) {
// The brand backdrop is drawn edge to edge, under the status bar and the navigation bar, and
// only the content is inset — that is what tints the system bars green. The gradient is light
// at the top and the wave band is dark at the bottom, so the two bars get opposite icon tints.
ConfigureSystemBars(darkStatusBarBackground = false, darkNavigationBarBackground = true)
val split = currentSurferWindowGeometry().supportsSplitLayout()
Box(
modifier = Modifier
.fillMaxSize()
.surferTestTagAsId()
.testTag(SignInTestTags.Root),
) {
SurferAuthBackground(
modifier = Modifier.fillMaxSize(),
gradient = if (split) SurferAuthGradient.Diagonal else SurferAuthGradient.Vertical,
)
Box(
modifier = Modifier
.fillMaxSize()
.surferSafeInsets()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)),
) {
if (split) {
SignInSplitLayout(state = state, onEvent = onEvent)
} else {
SignInStackedLayout(state = state, onEvent = onEvent)
}
}
if (state.isLoading) {
SurferFullScreenLoader(
modifier = Modifier
.fillMaxSize()
.testTag(SignInTestTags.Loader),
)
}
state.dialogError?.let { error ->
SignInErrorDialog(
error = error,
onDismiss = { onEvent(SignInEvent.OnErrorDismiss) },
)
}
}
}
/**
* Whether the brand block and the sheet sit side by side instead of stacked.
*
* Two conditions, both needed. [SurferWindowSize.Expanded] is the width at which two columns each
* keep a readable measure — below it (a tablet in portrait, the 834 dp artboard) the design still
* stacks. And the window has to be wider than it is tall: a 1024 dp tablet held in *portrait* is
* Expanded too, and splitting it would leave two tall, half-empty columns.
*
* The measurement is uikit's; which shape to draw from it is sign-in's, so this stays here until a
* second screen wants the same answer.
*/
private fun SurferWindowGeometry.supportsSplitLayout(): Boolean =
sizeClass >= SurferWindowSize.Expanded && isLandscape
/**
* Phone and tablet-portrait: brand on top, sheet pinned to the bottom.
*
* The bottom safe-drawing inset carries the IME, so the insets applied by the caller already lift
* the content clear of the keyboard. What was still missing is somewhere to lift it TO: with a
* fixed-height column the sheet was squeezed off-screen and the "Create account" button became
* unreachable. Measuring inside the inset padding yields a viewport height that already accounts
* for the keyboard, and the scroll container covers the rest.
*/
@Composable
private fun SignInStackedLayout(
state: SignInState,
onEvent: (SignInEvent) -> Unit,
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val viewportHeight = maxHeight
Column(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.widthIn(max = ContentMaxWidth)
.verticalScroll(rememberScrollState())
.heightIn(min = viewportHeight)
.padding(
start = AppTheme.spacing.default,
end = AppTheme.spacing.default,
top = AppTheme.spacing.xLarge,
bottom = AppTheme.spacing.large,
),
verticalArrangement = Arrangement.SpaceBetween,
) {
// Exactly two children so SpaceBetween reproduces the pinned-to-bottom sheet of the
// original weight(1f) layout, which a scrollable column cannot use.
Column(modifier = Modifier.padding(bottom = AppTheme.spacing.large)) {
SignInBrandHeader()
Spacer(Modifier.height(AppTheme.spacing.xLarge))
SignInHero()
}
SignInActionSheet(state = state, onEvent = onEvent)
}
}
}
/**
* Desktop and tablet-landscape: brand on the left, the sheet as a card on the right, both centred
* on the vertical axis. Stacking these would put the sheet below the fold on every landscape
* window, and the wave band — drawn along the bottom of the backdrop — would end up behind the
* card rather than beside it.
*/
@Composable
private fun SignInSplitLayout(
state: SignInState,
onEvent: (SignInEvent) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxHeight()
.surferContentContainer(maxWidth = SurferWideContentMaxWidth)
.padding(
horizontal = AppTheme.spacing.xLarge,
vertical = AppTheme.spacing.large,
),
horizontalArrangement = Arrangement.spacedBy(AppTheme.spacing.xLarge),
verticalAlignment = Alignment.CenterVertically,
) {
// The sheet is capped and the brand takes what is left, rather than the two splitting the
// row by percentage: the mockup's 46 % is only correct at the width it was drawn at, while
// the cap is the same measure the stacked layout uses and holds at any width.
Column(modifier = Modifier.weight(1f)) {
SignInBrandHeader()
Spacer(Modifier.height(AppTheme.spacing.xxLarge))
SignInHero(titleStyle = AppTheme.typography.displayMedium)
}
// The sheet scrolls on its own: the brand block always fits, but the form is taller than a
// short landscape window (a phone on its side is ~410 dp tall) and has to stay reachable.
BoxWithConstraints(
modifier = Modifier
.widthIn(max = ContentMaxWidth)
.fillMaxHeight(),
) {
val viewportHeight = maxHeight
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.heightIn(min = viewportHeight),
verticalArrangement = Arrangement.Center,
) {
SignInActionSheet(state = state, onEvent = onEvent)
}
}
}
}
@Composable
private fun SignInErrorDialog(
error: SignInError,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(Res.string.sign_in_error_dialog_title)) },
text = { Text(error.localized()) },
confirmButton = {
TextButton(
onClick = onDismiss,
// The dialog is its own window, so the hosting screen's
// `surferTestTagAsId()` does not reach in here — it has to be applied
// again alongside the tag, the way `SurferDeleteTransactionDialog`
// does. Without it the tag never becomes a resource-id and Maestro
// cannot see either node, even though the dialog is plainly on screen
// (#297).
modifier = Modifier
.surferTestTagAsId()
.testTag(SignInTestTags.ErrorDialogConfirm),
) {
Text(stringResource(Res.string.sign_in_error_dialog_ok))
}
},
containerColor = AppTheme.materialColors.surface,
modifier = Modifier
.surferTestTagAsId()
.testTag(SignInTestTags.ErrorDialog),
)
}
@Composable
private fun SignInBrandHeader(modifier: Modifier = Modifier) {
Row(
modifier = modifier.testTag(SignInTestTags.BrandHeader),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(AppTheme.spacing.medium),
) {
SurferAppIcon(size = BrandIconSize)
Text(
text = stringResource(Res.string.sign_in_brand),
style = AppTheme.typography.titleLarge,
color = AuthColors.OnBrand,
fontWeight = FontWeight.Bold,
)
}
}
@Composable
private fun SignInHero(
modifier: Modifier = Modifier,
titleStyle: TextStyle = AppTheme.typography.displaySmall,
) {
Column(modifier = modifier) {
Text(
text = stringResource(Res.string.sign_in_hero_title),
color = AuthColors.OnBrand,
// Size and leading come from the scale; only the brand's weight is set here, because
// the display styles are Normal and the hero is the one place that shouts.
style = titleStyle,
fontWeight = FontWeight.ExtraBold,
modifier = Modifier.testTag(SignInTestTags.HeroTitle),
)
Spacer(Modifier.height(AppTheme.spacing.default))
Text(
text = stringResource(Res.string.sign_in_hero_subtitle),
color = AuthColors.OnBrandMuted,
style = AppTheme.typography.bodyLarge,
modifier = Modifier
.widthIn(max = HeroSubtitleMaxWidth)
.testTag(SignInTestTags.HeroSubtitle),
)
}
}
@Composable
private fun SignInActionSheet(
state: SignInState,
onEvent: (SignInEvent) -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.testTag(SignInTestTags.Sheet),
shape = RoundedCornerShape(SheetCorner),
colors = CardDefaults.cardColors(
containerColor = AuthColors.Sheet,
contentColor = AuthColors.Ink,
),
elevation = CardDefaults.cardElevation(defaultElevation = SheetElevation),
) {
Column(modifier = Modifier.padding(SheetPadding)) {
SheetHeader()
Spacer(Modifier.height(AppTheme.spacing.default))
if (state.emailPasswordEnabled) {
EmailPasswordForm(state = state, onEvent = onEvent)
Spacer(Modifier.height(AppTheme.spacing.small))
// Only errors that belong to no single field land here; the rest are rendered
// as supporting text under the input that caused them.
val formError = state.formError
if (formError != null) {
Text(
text = formError.localized(),
style = AppTheme.typography.bodySmall,
color = AppTheme.materialColors.error,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.testTag(SignInTestTags.ErrorText),
)
Spacer(Modifier.height(AppTheme.spacing.small))
}
PrimaryFilledButton(
text = stringResource(
when (state.mode) {
AuthMode.SignIn -> Res.string.sign_in_submit_signin
AuthMode.SignUp -> Res.string.sign_in_submit_signup
},
),
onClick = { onEvent(SignInEvent.OnSubmitClick) },
enabled = state.canSubmit,
modifier = Modifier.testTag(SignInTestTags.SubmitButton),
)
Spacer(Modifier.height(AppTheme.spacing.xSmall))
TextButton(
onClick = { onEvent(SignInEvent.OnToggleModeClick) },
enabled = !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.testTag(SignInTestTags.ToggleModeButton),
) {
Text(
text = stringResource(
when (state.mode) {
AuthMode.SignIn -> Res.string.sign_in_toggle_to_signup
AuthMode.SignUp -> Res.string.sign_in_toggle_to_signin
},
),
color = AuthColors.PrimaryDark,
fontWeight = FontWeight.SemiBold,
)
}
}
if (state.anonymousEnabled) {
if (state.emailPasswordEnabled) {
OrDivider()
}
PasskeyOutlinedButton(
text = stringResource(Res.string.sign_in_anonymous),
onClick = { onEvent(SignInEvent.OnAnonymousLoginClick) },
enabled = !state.isLoading,
modifier = Modifier.testTag(SignInTestTags.AnonymousButton),
)
}
if (state.demoEnabled) {
Spacer(Modifier.height(AppTheme.spacing.small))
if (state.demoOnly) {
PrimaryFilledButton(
text = stringResource(Res.string.sign_in_demo_mode),
onClick = { onEvent(SignInEvent.OnLoginClick) },
enabled = !state.isLoading,
modifier = Modifier.testTag(SignInTestTags.DemoButton),
)
} else {
TextButton(
onClick = { onEvent(SignInEvent.OnLoginClick) },
enabled = !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.testTag(SignInTestTags.DemoButton),
) {
Text(
text = stringResource(Res.string.sign_in_demo_mode),
color = AuthColors.PrimaryDark,
fontWeight = FontWeight.SemiBold,
)
}
}
}
Spacer(Modifier.height(AppTheme.spacing.default))
SignInTerms(onClick = { onEvent(SignInEvent.OnTermsClick) })
}
}
}
@Composable
private fun SheetHeader() {
Text(
text = stringResource(Res.string.sign_in_sheet_title),
color = AuthColors.Ink,
fontSize = SheetTitleSize,
fontWeight = FontWeight.Bold,
modifier = Modifier.testTag(SignInTestTags.SheetTitle),
)
Spacer(Modifier.height(AppTheme.spacing.xxSmall))
Text(
text = stringResource(Res.string.sign_in_sheet_subtitle),
color = AuthColors.SheetMuted,
style = AppTheme.typography.bodySmall,
modifier = Modifier.testTag(SignInTestTags.SheetSubtitle),
)
}
@Composable
private fun EmailPasswordForm(
state: SignInState,
onEvent: (SignInEvent) -> Unit,
) {
val fieldColors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = AuthColors.PrimaryDark,
unfocusedBorderColor = AuthColors.Divider,
focusedLabelColor = AuthColors.PrimaryDark,
unfocusedLabelColor = AuthColors.SheetSubtle,
cursorColor = AuthColors.PrimaryDark,
focusedTextColor = AuthColors.Ink,
unfocusedTextColor = AuthColors.Ink,
)
SurferTextField(
value = state.email,
onValueChange = { onEvent(SignInEvent.OnEmailChanged(it)) },
label = stringResource(Res.string.sign_in_email_label),
errorText = state.emailError?.localized(),
enabled = !state.isLoading,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
),
colors = fieldColors,
fieldTestTag = SignInTestTags.EmailField,
)
// No spacer: each field already reserves a message line under itself, which is the gap.
SurferPasswordField(
value = state.password,
onValueChange = { onEvent(SignInEvent.OnPasswordChanged(it)) },
label = stringResource(Res.string.sign_in_password_label),
errorText = state.passwordError?.localized(),
enabled = !state.isLoading,
colors = fieldColors,
fieldTestTag = SignInTestTags.PasswordField,
)
}
@Composable
private fun PrimaryFilledButton(
text: String,
onClick: () -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier,
) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier
.fillMaxWidth()
.height(PrimaryButtonHeight),
shape = RoundedCornerShape(PrimaryButtonHeight / 2),
colors = ButtonDefaults.buttonColors(
containerColor = AuthColors.PrimaryDark,
contentColor = AuthColors.OnBrand,
),
) {
Text(
text = text,
fontSize = PrimaryLabelSize,
fontWeight = FontWeight.SemiBold,
)
}
}
@Composable
private fun PasskeyOutlinedButton(
text: String,
onClick: () -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier,
) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
modifier = modifier
.fillMaxWidth()
.height(PrimaryButtonHeight),
shape = RoundedCornerShape(PrimaryButtonHeight / 2),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = AuthColors.PrimaryDark,
),
) {
Icon(
imageVector = SurferIcons.Fingerprint,
contentDescription = SurferSemantics.Decorative,
modifier = Modifier.size(PasskeyIconSize),
)
Spacer(Modifier.width(AppTheme.spacing.small))
Text(
text = text,
fontSize = PrimaryLabelSize,
fontWeight = FontWeight.SemiBold,
)
}
}
@Composable
private fun OrDivider() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = AppTheme.spacing.small),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.weight(1f)
.height(1.dp)
.background(AuthColors.Divider),
)
Text(
text = stringResource(Res.string.sign_in_or).uppercase(),
color = AuthColors.SheetSubtle,
fontSize = OrLabelSize,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = AppTheme.spacing.small),
)
Box(
modifier = Modifier
.weight(1f)
.height(1.dp)
.background(AuthColors.Divider),
)
}
}
@Composable
private fun SignInTerms(onClick: () -> Unit) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(Res.string.sign_in_terms),
style = AppTheme.typography.bodySmall,
color = AuthColors.PrimaryDark,
textAlign = TextAlign.Center,
textDecoration = TextDecoration.Underline,
modifier = Modifier
.clickable(role = Role.Button, onClick = onClick)
.testTag(SignInTestTags.Terms),
)
}
}
@Preview
@Composable
private fun SignInScreenPreview() {
AppTheme {
SignInContent(
state = SignInState(),
onEvent = {},
)
}
}
@Preview
@Composable
private fun SignInScreenFieldErrorPreview() {
AppTheme {
SignInContent(
state = SignInState(
email = "surfer@example",
password = "123",
mode = AuthMode.SignUp,
error = SignInError.PasswordTooShort,
),
onEvent = {},
)
}
}
@Preview
@Composable
private fun SignInScreenDemoOnlyPreview() {
AppTheme {
SignInContent(
state = SignInState(
emailPasswordEnabled = false,
anonymousEnabled = false,
demoEnabled = true,
),
onEvent = {},
)
}
}