diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationDemoActivity.kt index d3cccd059b..55f9a722cf 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationDemoActivity.kt @@ -5,17 +5,24 @@ import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.core.content.edit import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.FirebaseAuthUI import com.firebase.ui.auth.configuration.AuthUIConfiguration @@ -25,8 +32,12 @@ import com.firebase.ui.auth.configuration.authUIConfiguration import com.firebase.ui.auth.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.configuration.theme.AuthUIAsset import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen +import com.firebase.ui.auth.util.EmailLinkConstants import com.firebaseui.android.demo.R -import com.firebaseui.android.demo.utils.EMAIL_LINK_ORIGIN_PARAM +import com.firebaseui.android.demo.auth.fullcustomization.common.ThemeBackground +import com.firebaseui.android.demo.auth.fullcustomization.common.ThemeSwitcher +import com.firebaseui.android.demo.auth.fullcustomization.professional.LegalLinks +import com.firebaseui.android.demo.auth.fullcustomization.professional.LocalLegalLinks import com.firebaseui.android.demo.auth.fullcustomization.screens.AuthMethodPickerUI import com.firebaseui.android.demo.auth.fullcustomization.screens.AuthenticatedUI import com.firebaseui.android.demo.auth.fullcustomization.screens.email.EmailAuthUI @@ -35,13 +46,17 @@ import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.MfaEnrollm import com.firebaseui.android.demo.auth.fullcustomization.screens.phone.PhoneSignInUI import com.firebaseui.android.demo.auth.fullcustomization.screens.reauth.ReauthUI import com.firebaseui.android.demo.auth.fullcustomization.theme.FullCustomizationTheme -import com.firebase.ui.auth.util.EmailLinkConstants +import com.firebaseui.android.demo.auth.fullcustomization.theme.FullCustomizationThemeId +import com.firebaseui.android.demo.utils.EMAIL_LINK_ORIGIN_PARAM import com.google.firebase.auth.actionCodeSettings class FullCustomizationDemoActivity : ComponentActivity() { companion object { /** Marks this demo's email links so MainActivity can route the return trip back here. */ const val EMAIL_LINK_ORIGIN = "fullcustomization" + + private const val THEME_PREFERENCES = "full_customization" + private const val THEME_KEY = "theme" } override fun onCreate(savedInstanceState: Bundle?) { @@ -97,47 +112,74 @@ class FullCustomizationDemoActivity : ComponentActivity() { } } + val preferences = getSharedPreferences(THEME_PREFERENCES, MODE_PRIVATE) + setContent { - FullCustomizationTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background + var themeId by remember { + mutableStateOf( + FullCustomizationThemeId.entries.firstOrNull { + it.name == preferences.getString(THEME_KEY, null) + } ?: FullCustomizationThemeId.Doggo + ) + } + FullCustomizationTheme(themeId = themeId) { + CompositionLocalProvider( + LocalLegalLinks provides LegalLinks(configuration.tosUrl, configuration.privacyPolicyUrl), ) { - FirebaseAuthScreen( - configuration = configuration, - authUI = authUI, - emailLink = emailLink, - onSignInSuccess = { result -> - Log.d("FullCustomizationDemo", "Auth success: ${result.user?.uid}") - }, - onSignInFailure = { exception: AuthException -> - Log.e("FullCustomizationDemo", "Auth failed", exception) - }, - onSignInCancelled = { - Log.d("FullCustomizationDemo", "Auth cancelled") - }, - mfaConfiguration = MfaConfiguration( - allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp), - requireEnrollment = false, - ), - customMethodPickerLayout = { providers, onProviderSelected -> - MainUI( - authUI = authUI, + Box(modifier = Modifier.fillMaxSize()) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + FirebaseAuthScreen( configuration = configuration, - providers = providers, - onProviderSelected = onProviderSelected, + authUI = authUI, + emailLink = emailLink, + onSignInSuccess = { result -> + Log.d("FullCustomizationDemo", "Auth success: ${result.user?.uid}") + }, + onSignInFailure = { exception: AuthException -> + Log.e("FullCustomizationDemo", "Auth failed", exception) + }, + onSignInCancelled = { + Log.d("FullCustomizationDemo", "Auth cancelled") + }, + mfaConfiguration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp), + requireEnrollment = false, + ), + customMethodPickerLayout = { providers, onProviderSelected -> + MainUI( + authUI = authUI, + configuration = configuration, + providers = providers, + onProviderSelected = onProviderSelected, + ) + }, + // Covers the email flows the library navigates to itself. + emailContent = { state -> EmailAuthUI(state) }, + phoneContent = { state -> PhoneSignInUI(state) }, + mfaEnrollmentContent = { state -> MfaEnrollmentUI(state) }, + mfaChallengeContent = { state -> MfaChallengeUI(state) }, + reauthContent = { state -> ReauthUI(state) }, + authenticatedContent = { state, uiContext -> + AuthenticatedUI(state = state, uiContext = uiContext) + }, ) - }, - // Covers the email flows the library navigates to itself. - emailContent = { state -> EmailAuthUI(state) }, - phoneContent = { state -> PhoneSignInUI(state) }, - mfaEnrollmentContent = { state -> MfaEnrollmentUI(state) }, - mfaChallengeContent = { state -> MfaChallengeUI(state) }, - reauthContent = { state -> ReauthUI(state) }, - authenticatedContent = { state, uiContext -> - AuthenticatedUI(state = state, uiContext = uiContext) - }, - ) + } + // Over every screen, so the theme can be switched mid-flow. + ThemeSwitcher( + selected = themeId, + onSelect = { selected -> + themeId = selected + preferences.edit { putString(THEME_KEY, selected.name) } + }, + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(top = 8.dp), + ) + } } } } @@ -153,12 +195,7 @@ private fun MainUI( ) { val context = LocalContext.current Box(modifier = Modifier.fillMaxSize()) { - Image( - painter = painterResource(id = R.drawable.custom_background), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) + ThemeBackground() Column(modifier = Modifier.fillMaxSize()) { // AuthMethodPickerUI builds its own EmailAuthScreen per step, so none is needed here. AuthMethodPickerUI( diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthPage.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthPage.kt index 8bb588dfdb..cccd4a48ae 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthPage.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthPage.kt @@ -1,7 +1,6 @@ package com.firebaseui.android.demo.auth.fullcustomization.common import androidx.annotation.DrawableRes -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -14,20 +13,18 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProHeader +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProPage +import com.firebaseui.android.demo.auth.fullcustomization.professional.isProfessional +import com.firebaseui.android.demo.auth.fullcustomization.theme.LocalFullCustomizationStyle /** * The page frame shared by the MFA and reauthentication screens: mascot, headline, a single @@ -50,14 +47,22 @@ fun AuthPage( actions: @Composable ColumnScope.() -> Unit, card: @Composable ColumnScope.() -> Unit, ) { + // Professional lays the same content out as a plain left-aligned form, with no card. + if (isProfessional()) { + ProPage( + modifier = Modifier.semantics { contentDescription = cardContentDescription }, + header = { ProHeader(title = title) }, + ) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp), content = card) + Spacer(modifier = Modifier.height(24.dp)) + Column(content = actions) + } + return + } + Box(modifier = Modifier.fillMaxSize()) { // Outside safeDrawingPadding so it runs edge to edge under the system bars. - Image( - painter = painterResource(id = R.drawable.custom_background), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) + ThemeBackground() BoxWithConstraints( modifier = Modifier @@ -74,16 +79,10 @@ fun AuthPage( Spacer(modifier = Modifier.weight(1f)) Column(modifier = Modifier.fillMaxWidth()) { - Image( - painter = painterResource(id = mascot), - contentDescription = mascotDescription, - modifier = Modifier.size(72.dp), - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = title, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.primary, + ThemeHeader( + title = title, + mascot = mascot, + mascotDescription = mascotDescription, ) Spacer(modifier = Modifier.height(24.dp)) @@ -92,7 +91,7 @@ fun AuthPage( modifier = Modifier .fillMaxWidth() .semantics { contentDescription = cardContentDescription }, - color = MaterialTheme.colorScheme.surface, + color = LocalFullCustomizationStyle.current.cardFill, shape = AuthFieldShape, ) { Column( diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthTextFieldStyle.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthTextFieldStyle.kt index ca66ee7bc2..1907110566 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthTextFieldStyle.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthTextFieldStyle.kt @@ -1,34 +1,62 @@ package com.firebaseui.android.demo.auth.fullcustomization.common import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +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.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextFieldColors import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.theme.FullCustomizationThemeId +import com.firebaseui.android.demo.auth.fullcustomization.theme.LocalFullCustomizationStyle val AuthFieldShape = RoundedCornerShape(24.dp) @Composable fun authTextFieldColors(): TextFieldColors = OutlinedTextFieldDefaults.colors( - unfocusedContainerColor = Color.White, - focusedContainerColor = Color.White, - disabledContainerColor = Color.White, + unfocusedContainerColor = LocalFullCustomizationStyle.current.fieldContainer, + focusedContainerColor = LocalFullCustomizationStyle.current.fieldContainer, + disabledContainerColor = LocalFullCustomizationStyle.current.fieldContainer, unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, focusedBorderColor = MaterialTheme.colorScheme.secondary, ) +/** + * An outlined text field in the theme's field shape. Built on [BasicTextField] rather than + * `OutlinedTextField` because only the decoration box takes an unfocused border width, and Grid and + * Coffee Spark draw theirs at 2dp. + */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun FullCustomizationTextField( value: String, @@ -44,25 +72,111 @@ fun FullCustomizationTextField( singleLine: Boolean = true, visualTransformation: VisualTransformation = VisualTransformation.None, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, - shape: Shape = AuthFieldShape, + shape: Shape = LocalFullCustomizationStyle.current.fieldShape, + /** Professional only: an action at the end of the label row, such as "Forgot password?". */ + labelAction: (@Composable () -> Unit)? = null, ) { - OutlinedTextField( + val colors = authTextFieldColors() + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val textColor = when { + !enabled -> colors.disabledTextColor + isError -> colors.errorTextColor + isFocused -> colors.focusedTextColor + else -> colors.unfocusedTextColor + } + // Professional sets the label above a 48dp field, as a plain form does; the others float it. + val compact = LocalFullCustomizationStyle.current.id == FullCustomizationThemeId.Professional + val labelAbove = compact && label != null + val labelSlot: (@Composable () -> Unit)? = label?.takeUnless { labelAbove }?.let { { Text(it) } } + val focusRequester = remember { FocusRequester() } + val fieldModifier = when { + labelAbove -> Modifier.fillMaxWidth().focusRequester(focusRequester) + // OutlinedTextField's top inset, so a floating label isn't clipped by the field above. + label != null -> modifier.semantics(mergeDescendants = true) {}.padding(top = 8.dp) + else -> modifier + } + + val field: @Composable () -> Unit = { BasicTextField( value = value, onValueChange = onValueChange, - modifier = modifier, - label = label?.let { { Text(it) } }, - placeholder = placeholder?.let { { Text(it) } }, - leadingIcon = leadingIcon, - trailingIcon = trailingIcon, + modifier = fieldModifier.defaultMinSize( + minWidth = OutlinedTextFieldDefaults.MinWidth, + minHeight = if (compact) 48.dp else OutlinedTextFieldDefaults.MinHeight, + ), enabled = enabled, - isError = isError, - supportingText = supportingText?.let { { Text(it) } }, + textStyle = LocalTextStyle.current.merge(color = textColor), + keyboardOptions = keyboardOptions, singleLine = singleLine, + maxLines = if (singleLine) 1 else Int.MAX_VALUE, visualTransformation = visualTransformation, - keyboardOptions = keyboardOptions, - shape = shape, - colors = authTextFieldColors(), - ) + interactionSource = interactionSource, + cursorBrush = SolidColor(if (isError) colors.errorCursorColor else colors.cursorColor), + decorationBox = { innerTextField -> + OutlinedTextFieldDefaults.DecorationBox( + value = value, + innerTextField = innerTextField, + enabled = enabled, + singleLine = singleLine, + visualTransformation = visualTransformation, + interactionSource = interactionSource, + isError = isError, + label = labelSlot, + placeholder = placeholder?.let { { Text(it) } }, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + supportingText = supportingText?.let { { Text(it) } }, + colors = colors, + contentPadding = if (compact) { + OutlinedTextFieldDefaults.contentPadding(start = 14.dp, top = 12.dp, end = 14.dp, bottom = 12.dp) + } else { + OutlinedTextFieldDefaults.contentPadding() + }, + container = { + OutlinedTextFieldDefaults.Container( + enabled = enabled, + isError = isError, + interactionSource = interactionSource, + colors = colors, + shape = shape, + // 1dp heavier than unfocused, so focus still shows where both colours match. + focusedBorderThickness = LocalFullCustomizationStyle.current.fieldBorderWidth + 1.dp, + unfocusedBorderThickness = LocalFullCustomizationStyle.current.fieldBorderWidth, + ) + }, + ) + }, + ) } + + if (labelAbove) { + // The whole block focuses the field, as a form label does, so a tap on the label lands. + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = modifier + .semantics(mergeDescendants = true) {} + .clickable( + enabled = enabled, + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { focusRequester.requestFocus() }, + ) { + // On the text baseline, so a label action set at another size still lines up. + Row { + Text( + text = label.orEmpty(), + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .weight(1f) + .alignByBaseline(), + ) + labelAction?.let { action -> Box(modifier = Modifier.alignByBaseline()) { action() } } + } + field() + } + } else { + field() + } } @Composable @@ -70,6 +184,12 @@ fun EmailFieldIcon() { Image( painter = painterResource(R.drawable.email_at_sign), contentDescription = null, + // The art is Doggo's; the other themes recolour it to their field text. + colorFilter = if (LocalFullCustomizationStyle.current.showsMascot) { + null + } else { + ColorFilter.tint(MaterialTheme.colorScheme.onSurfaceVariant) + }, modifier = Modifier.size(24.dp), ) } diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/CtaButton.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/CtaButton.kt index edb251c936..358dde97eb 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/CtaButton.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/CtaButton.kt @@ -14,9 +14,19 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.firebaseui.android.demo.auth.fullcustomization.theme.ButtonShape +import com.firebaseui.android.demo.auth.fullcustomization.theme.LocalFullCustomizationStyle -private val CtaShadowColor = Color(0xFF5D0B47) +/** The theme's main call-to-action colours. */ +@Composable +fun primaryCtaColors(): ButtonColors = LocalFullCustomizationStyle.current.primaryButton.let { + ButtonDefaults.buttonColors(containerColor = it.container, contentColor = it.content) +} + +/** The theme's colours for a call to action that reads as an alternative to the main one. */ +@Composable +fun secondaryCtaColors(): ButtonColors = LocalFullCustomizationStyle.current.secondaryButton.let { + ButtonDefaults.buttonColors(containerColor = it.container, contentColor = it.content) +} @Composable fun CtaButton( @@ -25,24 +35,27 @@ fun CtaButton( modifier: Modifier = Modifier, enabled: Boolean = true, isLoading: Boolean = false, - colors: ButtonColors = ButtonDefaults.buttonColors(), + colors: ButtonColors = primaryCtaColors(), ) { + val style = LocalFullCustomizationStyle.current + val shadow = style.buttonShadow HardOffsetShadow( - shape = ButtonShape, - offsetX = 2.dp, - offsetY = 4.dp, - color = if (enabled) CtaShadowColor else Color.Transparent, + shape = style.buttonShape, + offsetX = shadow?.offset?.x ?: 0.dp, + offsetY = shadow?.offset?.y ?: 0.dp, + color = if (enabled && shadow != null) shadow.color else Color.Transparent, modifier = modifier.fillMaxWidth(), ) { Button( onClick = onClick, enabled = enabled, - shape = ButtonShape, + shape = style.buttonShape, colors = colors, + border = style.buttonBorder, contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp), modifier = Modifier .fillMaxWidth() - .height(80.dp), + .height(style.buttonHeight), ) { if (isLoading) { // M3 default (primary) reads on the disabled fill; LocalContentColor would wash it out. diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/HardOffsetShadow.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/HardOffsetShadow.kt index 628bffafd2..208a999430 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/HardOffsetShadow.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/HardOffsetShadow.kt @@ -3,21 +3,22 @@ package com.firebaseui.android.demo.auth.fullcustomization.common import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.offset -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.firebaseui.android.demo.auth.fullcustomization.theme.LocalFullCustomizationStyle @Composable fun HardOffsetShadow( shape: Shape, modifier: Modifier = Modifier, - offsetX: Dp = 3.dp, - offsetY: Dp = 6.dp, - color: Color = MaterialTheme.colorScheme.primaryContainer, + // Defaults to the theme's card shadow, which Grid and Coffee Spark don't draw. + offsetX: Dp = LocalFullCustomizationStyle.current.cardShadow?.offset?.x ?: 0.dp, + offsetY: Dp = LocalFullCustomizationStyle.current.cardShadow?.offset?.y ?: 0.dp, + color: Color = LocalFullCustomizationStyle.current.cardShadow?.color ?: Color.Transparent, content: @Composable () -> Unit, ) { Box(modifier = modifier) { diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/SheetProviderButton.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/SheetProviderButton.kt index 905774fe0e..8c461498b7 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/SheetProviderButton.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/SheetProviderButton.kt @@ -21,6 +21,7 @@ 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.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow @@ -28,7 +29,7 @@ import androidx.compose.ui.unit.dp import com.firebase.ui.auth.configuration.auth_provider.AuthProvider import com.firebase.ui.auth.configuration.theme.AuthUIAsset import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults -import com.firebaseui.android.demo.auth.fullcustomization.theme.ProviderButtonShape +import com.firebaseui.android.demo.auth.fullcustomization.theme.LocalFullCustomizationStyle @Composable fun SheetProviderButton( @@ -37,28 +38,23 @@ fun SheetProviderButton( modifier: Modifier = Modifier, ) { val label = providerSheetLabel(provider) - val style = when (provider) { - is AuthProvider.Google -> ProviderStyleDefaults.Google - is AuthProvider.Facebook -> ProviderStyleDefaults.Facebook - is AuthProvider.Twitter -> ProviderStyleDefaults.Twitter - is AuthProvider.Github -> ProviderStyleDefaults.Github - is AuthProvider.Microsoft -> ProviderStyleDefaults.Microsoft - is AuthProvider.Yahoo -> ProviderStyleDefaults.Yahoo - is AuthProvider.Apple -> ProviderStyleDefaults.Apple - is AuthProvider.Anonymous -> ProviderStyleDefaults.Anonymous - else -> ProviderStyleDefaults.Email - } + val style = providerStyleFor(provider) + val theme = LocalFullCustomizationStyle.current val backgroundColor = if (provider is AuthProvider.Phone) { - MaterialTheme.colorScheme.primary + theme.phoneProvider.container } else { style.backgroundColor } - val contentColor = if (provider is AuthProvider.Google) Color.Black else style.contentColor + val contentColor = when (provider) { + is AuthProvider.Google -> Color.Black + is AuthProvider.Phone -> theme.phoneProvider.content + else -> style.contentColor + } val hasWhiteBackground = backgroundColor == Color.White Button( onClick = onClick, - shape = ProviderButtonShape, + shape = theme.providerButtonShape, colors = ButtonDefaults.buttonColors( containerColor = backgroundColor, contentColor = contentColor, @@ -101,6 +97,23 @@ fun SheetProviderButton( } } +private fun providerStyleFor(provider: AuthProvider) = when (provider) { + is AuthProvider.Google -> ProviderStyleDefaults.Google + is AuthProvider.Facebook -> ProviderStyleDefaults.Facebook + is AuthProvider.Twitter -> ProviderStyleDefaults.Twitter + is AuthProvider.Github -> ProviderStyleDefaults.Github + is AuthProvider.Microsoft -> ProviderStyleDefaults.Microsoft + is AuthProvider.Yahoo -> ProviderStyleDefaults.Yahoo + is AuthProvider.Apple -> ProviderStyleDefaults.Apple + is AuthProvider.Anonymous -> ProviderStyleDefaults.Anonymous + else -> ProviderStyleDefaults.Email +} + +/** The library's brand mark for [provider], or null when it has none (phone). */ +@Composable +internal fun providerIconPainter(provider: AuthProvider): Painter? = + providerStyleFor(provider).icon?.asPainter() + private fun providerSheetLabel(provider: AuthProvider): String = when (provider) { is AuthProvider.Google -> "Sign in with Google" is AuthProvider.Facebook -> "Sign in with Facebook" diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/ThemeChrome.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/ThemeChrome.kt new file mode 100644 index 0000000000..6f0f5a860f --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/ThemeChrome.kt @@ -0,0 +1,226 @@ +package com.firebaseui.android.demo.auth.fullcustomization.common + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Coffee +import androidx.compose.material.icons.outlined.Palette +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.theme.FullCustomizationThemeId +import com.firebaseui.android.demo.auth.fullcustomization.theme.gridPattern +import com.firebaseui.android.demo.auth.fullcustomization.theme.JetBrainsMono +import com.firebaseui.android.demo.auth.fullcustomization.theme.LocalFullCustomizationStyle + +/** + * The theme's page background, edge to edge. Grid and Coffee Spark place their art at the iOS + * frames' offsets from the top-left corner: "Frame 5" in 20:5467 and "avatar spark" in 20:5769. + */ +@Composable +fun ThemeBackground() { + val style = LocalFullCustomizationStyle.current + val page = MaterialTheme.colorScheme.background + when (style.id) { + FullCustomizationThemeId.Doggo -> Image( + painter = painterResource(id = R.drawable.custom_background), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + FullCustomizationThemeId.Grid -> PlacedArt( + art = rememberVectorPainter(remember(page) { gridPattern(page) }), + x = (-269).dp, y = (-75).dp, width = 997.dp, height = 997.dp, + ) + // The export includes the layer's texture and noise effects, so it is larger than the + // 583x568 layer and centred on it. + FullCustomizationThemeId.Spark -> PlacedArt( + art = painterResource(id = R.drawable.full_customization_spark_blob), + x = (-295.8).dp, y = (-317.7).dp, width = 751.dp, height = 735.3.dp, + ) + FullCustomizationThemeId.Professional -> + Box(modifier = Modifier.fillMaxSize().background(page)) + } + // The art is drawn for a light page; dark mode washes it back toward the dark one. + Box(modifier = Modifier.fillMaxSize().background(style.artScrim)) +} + +@Composable +private fun PlacedArt(art: Painter, x: Dp, y: Dp, width: Dp, height: Dp) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .clipToBounds(), + ) { + Image( + painter = art, + contentDescription = null, + contentScale = ContentScale.FillBounds, + // requiredSize so art larger than the screen keeps its size instead of being squeezed. + modifier = Modifier + .wrapContentSize(align = Alignment.TopStart, unbounded = true) + .offset(x = x, y = y) + .requiredSize(width, height), + ) + } +} + +/** + * A screen's heading: Doggo's [mascot] above the title, Coffee Spark's headline pill above it, and + * the title alone on Grid. + */ +@Composable +fun ThemeHeader( + title: String, + @DrawableRes mascot: Int, + mascotDescription: String, + mascotSize: Dp = 72.dp, +) { + val style = LocalFullCustomizationStyle.current + when (style.id) { + FullCustomizationThemeId.Doggo -> { + Image( + painter = painterResource(id = mascot), + contentDescription = mascotDescription, + modifier = Modifier.size(mascotSize), + ) + Spacer(modifier = Modifier.height(8.dp)) + } + FullCustomizationThemeId.Spark -> { + SparkHeadline() + Spacer(modifier = Modifier.height(24.dp)) + } + FullCustomizationThemeId.Grid, FullCustomizationThemeId.Professional -> Unit + } + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = style.titleColor, + ) +} + +/** Figma "headline" (20:5927), Coffee Spark only. */ +@Composable +fun SparkHeadline(modifier: Modifier = Modifier) { + val pill = LocalFullCustomizationStyle.current.pill + Surface( + color = pill.container, + contentColor = pill.content, + shape = RoundedCornerShape(24.dp), + modifier = modifier + .height(48.dp) + .semantics(mergeDescendants = true) { contentDescription = "headline - Coffee Spark" }, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Icon(imageVector = Icons.Outlined.Coffee, contentDescription = null) + Text( + text = "Coffee Spark", + style = TextStyle(fontFamily = JetBrainsMono, fontSize = 20.sp), + ) + } + } +} + +/** A field-like outline: the theme's field shape and border width in `outlineVariant`. */ +@Composable +fun Modifier.themeFieldBorder(): Modifier { + val style = LocalFullCustomizationStyle.current + return border( + width = style.fieldBorderWidth, + color = MaterialTheme.colorScheme.outlineVariant, + shape = style.fieldShape, + ) +} + +/** Switches the demo between its themes live, on top of whatever screen is showing. */ +@Composable +fun ThemeSwitcher( + selected: FullCustomizationThemeId, + onSelect: (FullCustomizationThemeId) -> Unit, + modifier: Modifier = Modifier, +) { + val pill = LocalFullCustomizationStyle.current.pill + Surface( + color = pill.container, + contentColor = pill.content, + shape = RoundedCornerShape(percent = 50), + shadowElevation = 2.dp, + modifier = modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .padding(4.dp) + .selectableGroup() + .semantics { contentDescription = "theme-switcher" }, + ) { + Icon( + imageVector = Icons.Outlined.Palette, + contentDescription = null, + modifier = Modifier + .padding(start = 8.dp, end = 4.dp) + .size(18.dp), + ) + FullCustomizationThemeId.entries.forEach { theme -> + val isSelected = theme == selected + Text( + text = theme.label, + style = MaterialTheme.typography.labelLarge, + color = if (isSelected) pill.container else pill.content, + modifier = Modifier + .background( + color = if (isSelected) pill.content else Color.Transparent, + shape = RoundedCornerShape(percent = 50), + ) + .selectable( + selected = isSelected, + role = Role.RadioButton, + onClick = { onSelect(theme) }, + ) + .padding(horizontal = 12.dp, vertical = 14.dp), + ) + } + } + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/professional/ProEmailScreens.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/professional/ProEmailScreens.kt new file mode 100644 index 0000000000..10d26302a3 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/professional/ProEmailScreens.kt @@ -0,0 +1,349 @@ +package com.firebaseui.android.demo.auth.fullcustomization.professional + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.MarkEmailUnread +import androidx.compose.material.icons.outlined.MoreHoriz +import androidx.compose.material.icons.outlined.Phone +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.foundation.clickable +import androidx.compose.ui.semantics.Role +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState +import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton +import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField +import com.firebaseui.android.demo.auth.fullcustomization.common.providerIconPainter + +/** The providers the sign-in screen shows itself, as the iOS demo does; the rest are one tap away. */ +fun proInlineProviders(providers: List): List = listOfNotNull( + providers.firstOrNull { it is AuthProvider.Google }, + providers.firstOrNull { it is AuthProvider.Phone }, +) + +private fun isValidEmail(email: String) = android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches() + +/** + * The professional sign-in screen: the providers first, then email and password on the same + * screen. Creating an account, resetting a password and email-link sign-in are their own screens. + * + * @param onShowOtherMethods Opens the sheet with the remaining providers; null hides that button. + * @param onBack Shown only where this screen was pushed over another. + */ +@Composable +fun ProSignIn( + state: EmailAuthContentState, + isEmailLinkOffered: Boolean, + inlineProviders: List = emptyList(), + onProviderSelected: (AuthProvider) -> Unit = {}, + onShowOtherMethods: (() -> Unit)? = null, + onBack: (() -> Unit)? = null, +) { + val colors = proColors() + ProPage( + footer = { ProLegalText() }, + header = { + onBack?.let { ProBackButton(onBack = it, enabled = !state.isLoading) } + ProBrand() + Spacer(modifier = Modifier.height(28.dp)) + ProHeader(title = "Sign in to your account", subtitle = "Welcome back. Choose how you'd like to continue.") + }, + ) { + if (inlineProviders.isNotEmpty() || onShowOtherMethods != null) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + inlineProviders.forEach { provider -> + val title = if (provider is AuthProvider.Phone) "Continue with phone" else "Continue with Google" + ProOutlineButton( + title = title, + onClick = { onProviderSelected(provider) }, + contentDescription = "button - $title", + enabled = !state.isLoading, + ) { + if (provider is AuthProvider.Phone) { + Icon(imageVector = Icons.Outlined.Phone, contentDescription = null, modifier = Modifier.padding(1.dp)) + } else { + providerIconPainter(provider)?.let { ProProviderMark(it) } + } + } + } + onShowOtherMethods?.let { show -> + ProOutlineButton( + title = "More sign-in options", + onClick = show, + contentDescription = "Other sign-in methods button", + enabled = !state.isLoading, + ) { + Icon(imageVector = Icons.Outlined.MoreHoriz, contentDescription = null) + } + } + } + Column(modifier = Modifier.padding(vertical = 22.dp)) { ProDivider(label = "or continue with email") } + } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ProEmailField(state) + ProPasswordField( + value = state.password, + onValueChange = state.onPasswordChange, + label = "Password", + placeholder = "Enter your password", + enabled = !state.isLoading, + contentDescription = "text-field - password secure input", + content = { + Text( + text = "Forgot password?", + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = colors.accent, + modifier = Modifier + .clickable(enabled = !state.isLoading, role = Role.Button, onClick = state.onGoToResetPassword) + .semantics { contentDescription = "button - forgot password" }, + ) + }, + ) + ProErrorText(state.error) + CtaButton( + text = "Sign in", + onClick = state.onSignInClick, + enabled = isValidEmail(state.email) && state.password.isNotBlank() && !state.isLoading, + isLoading = state.isLoading, + modifier = Modifier + .padding(top = 4.dp) + .semantics { contentDescription = "button - login" }, + ) + } + if (isEmailLinkOffered) { + Spacer(modifier = Modifier.height(4.dp)) + ProTextButton( + title = "Email me a sign-in link", + onClick = state.onGoToEmailLinkSignIn, + contentDescription = "button - email link", + enabled = !state.isLoading, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + ProPromptLink( + prompt = "Don't have an account?", + action = "Create account", + onClick = state.onGoToSignUp, + contentDescription = "button - create account", + ) + } +} + +/** Account creation, pushed over sign-in. */ +@Composable +fun ProSignUp(state: EmailAuthContentState, onBack: () -> Unit) { + val passwordsMatch = state.confirmPassword.isNotBlank() && state.confirmPassword == state.password + val canSignUp = isValidEmail(state.email) && state.password.isNotBlank() && passwordsMatch && !state.isLoading + + ProPage( + footer = { ProLegalText(prefix = "By creating an account, you agree to the") }, + header = { + ProBackButton(onBack = onBack, enabled = !state.isLoading) + ProHeader(title = "Create your account", subtitle = "Sign up with your email. It takes less than a minute.") + }, + ) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ProEmailField(state, contentDescription = "text-field - email address display") + ProPasswordField( + value = state.password, + onValueChange = state.onPasswordChange, + label = "Password", + placeholder = "Create a password", + enabled = !state.isLoading, + contentDescription = "text-field - password", + ) + ProPasswordField( + value = state.confirmPassword, + onValueChange = state.onConfirmPasswordChange, + label = "Confirm password", + placeholder = "Repeat your password", + enabled = !state.isLoading, + isError = state.confirmPassword.isNotBlank() && !passwordsMatch, + supportingText = if (state.confirmPassword.isNotBlank() && !passwordsMatch) "Passwords don't match" else null, + contentDescription = "text-field - confirm password", + ) + ProErrorText(state.error) + CtaButton( + text = "Create account", + onClick = state.onSignUpClick, + enabled = canSignUp, + isLoading = state.isLoading, + modifier = Modifier + .padding(top = 4.dp) + .semantics { contentDescription = "button - sign up" }, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + ProPromptLink( + prompt = "Already have an account?", + action = "Sign in", + onClick = onBack, + contentDescription = "button - back to sign in", + ) + } +} + +/** Password reset, pushed over sign-in; becomes "Check your inbox" once the link is sent. */ +@Composable +fun ProResetPassword(state: EmailAuthContentState, onBack: () -> Unit) { + if (state.resetLinkSent) { + ProPage(header = { + ProBackButton(onBack = onBack) + ProHeader( + icon = Icons.Outlined.MarkEmailUnread, + title = "Check your inbox", + subtitle = "We sent a link to reset your password to ${state.email}.", + ) + }) { + CtaButton( + text = "Back to sign in", + onClick = onBack, + modifier = Modifier.semantics { contentDescription = "button - back to sign in" }, + ) + } + return + } + ProPage(header = { + ProBackButton(onBack = onBack, enabled = !state.isLoading) + ProHeader( + icon = Icons.Outlined.Lock, + title = "Reset your password", + subtitle = "Enter the email you sign in with and we'll send you a link to choose a new password.", + ) + }) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ProEmailField(state) + ProErrorText(state.error) + CtaButton( + text = "Send reset link", + onClick = state.onSendResetLinkClick, + enabled = isValidEmail(state.email) && !state.isLoading, + isLoading = state.isLoading, + modifier = Modifier.semantics { contentDescription = "button - send reset link" }, + ) + } + } +} + +/** Email-link sign-in, pushed over sign-in; becomes "Check your inbox" once the link is sent. */ +@Composable +fun ProEmailLink(state: EmailAuthContentState, onBack: () -> Unit) { + if (state.emailSignInLinkSent) { + ProPage(header = { + ProBackButton(onBack = onBack) + ProHeader( + icon = Icons.Outlined.MarkEmailUnread, + title = "Check your inbox", + subtitle = "We sent a sign-in link to ${state.email}. Open it on this device to finish signing in.", + ) + }) { + ProTextButton( + title = "Use a different email", + onClick = onBack, + contentDescription = "button - use a different email", + ) + } + return + } + ProPage(header = { + ProBackButton(onBack = onBack, enabled = !state.isLoading) + ProHeader( + icon = Icons.Outlined.Email, + title = "Sign in with a link", + subtitle = "We'll email you a link that signs you in, no password needed.", + ) + }) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ProEmailField(state) + ProErrorText(state.error) + CtaButton( + text = "Send link", + onClick = state.onSignInEmailLinkClick, + enabled = isValidEmail(state.email) && !state.isLoading, + isLoading = state.isLoading, + modifier = Modifier.semantics { contentDescription = "button - send login link" }, + ) + } + } +} + +@Composable +private fun ProEmailField(state: EmailAuthContentState, contentDescription: String = "text-field - email address input") { + FullCustomizationTextField( + value = state.email, + onValueChange = state.onEmailChange, + label = "Email", + placeholder = "name@company.com", + // Locked while reauthenticating: the address is the signed-in user's. + enabled = !state.isLoading && !state.isEmailLocked, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier + .fillMaxWidth() + .semantics { this.contentDescription = contentDescription }, + ) +} + +@Composable +private fun ProPasswordField( + value: String, + onValueChange: (String) -> Unit, + label: String, + placeholder: String, + enabled: Boolean, + contentDescription: String, + isError: Boolean = false, + supportingText: String? = null, + /** An action on the label row, such as "Forgot password?". */ + content: (@Composable () -> Unit)? = null, +) { + var revealed by remember { mutableStateOf(false) } + FullCustomizationTextField( + value = value, + onValueChange = onValueChange, + label = label, + placeholder = placeholder, + labelAction = content, + enabled = enabled, + isError = isError, + supportingText = supportingText, + visualTransformation = if (revealed) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { revealed = !revealed }) { + Icon( + imageVector = if (revealed) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (revealed) "Hide password" else "Show password", + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .semantics { this.contentDescription = contentDescription }, + ) +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/professional/ProKit.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/professional/ProKit.kt new file mode 100644 index 0000000000..c9597eeaf8 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/professional/ProKit.kt @@ -0,0 +1,505 @@ +package com.firebaseui.android.demo.auth.fullcustomization.professional + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +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.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.outlined.ErrorOutline +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Shield +import androidx.compose.material3.Icon +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.firebaseui.android.demo.auth.fullcustomization.theme.FullCustomizationThemeId +import com.firebaseui.android.demo.auth.fullcustomization.theme.LocalFullCustomizationStyle +import com.firebaseui.android.demo.auth.fullcustomization.theme.ProColors +import com.firebaseui.android.demo.auth.fullcustomization.theme.ProDarkColors +import com.firebaseui.android.demo.auth.fullcustomization.theme.ProLightColors + +/** The configuration's Terms and Privacy URLs, for [ProLegalText]. */ +@Immutable +data class LegalLinks(val tosUrl: String?, val privacyPolicyUrl: String?) + +val LocalLegalLinks = staticCompositionLocalOf { LegalLinks(null, null) } + +/** Whether the screens should take the professional layout rather than the shared one. */ +@Composable +fun isProfessional(): Boolean = + LocalFullCustomizationStyle.current.id == FullCustomizationThemeId.Professional + +/** Professional's palette for the current mode; the theme picks dark the same way. */ +@Composable +fun proColors(): ProColors = if (isSystemInDarkTheme()) ProDarkColors else ProLightColors + +/** + * The scaffold every professional screen shares: a scrolling, left-aligned column with a header, + * the content and an optional footer. The footer sits at the bottom when the content is short and + * scrolls with it otherwise. Inset 32dp and capped at 480dp, as the iOS demo is. + */ +@Composable +fun ProPage( + modifier: Modifier = Modifier, + footer: @Composable () -> Unit = {}, + header: @Composable ColumnScope.() -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + val colors = proColors() + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(colors.background) + .safeDrawingPadding(), + ) { + Column( + modifier = Modifier + .align(Alignment.TopCenter) + .widthIn(max = 480.dp) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + // heightIn(min = maxHeight) pins the footer to the bottom when everything fits. + .heightIn(min = maxHeight) + // The top inset clears the floating theme switcher. + .padding(start = 32.dp, end = 32.dp, top = 88.dp, bottom = 16.dp), + ) { + Column(content = header) + Column( + modifier = modifier + .fillMaxWidth() + .padding(top = 28.dp), + content = content, + ) + Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(32.dp)) + footer() + } + } +} + +/** The product mark and name, above the landing's title. */ +@Composable +fun ProBrand() { + val colors = proColors() + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + ProIconTile(icon = Icons.Outlined.Shield, size = 32.dp, fill = colors.accentFill, tint = Color.White) + Text(text = "FirebaseUI Demo", fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.text) + } +} + +/** A title and a one-line explanation, under an optional icon tile. */ +@Composable +fun ProHeader(title: String, subtitle: String? = null, icon: ImageVector? = null) { + val colors = proColors() + Column { + icon?.let { + ProIconTile(icon = it, size = 44.dp) + Spacer(modifier = Modifier.height(20.dp)) + } + Text( + text = title, + fontSize = 26.sp, + lineHeight = 32.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.3).sp, + color = colors.text, + modifier = Modifier.semantics { heading() }, + ) + subtitle?.let { + Spacer(modifier = Modifier.height(6.dp)) + Text(text = it, fontSize = 15.sp, lineHeight = 21.sp, color = colors.muted) + } + } +} + +/** An icon on a soft accent square. */ +@Composable +fun ProIconTile( + icon: ImageVector, + size: Dp = 36.dp, + fill: Color = proColors().accentSoft, + tint: Color = proColors().accentSoftText, +) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(size) + .background(fill, RoundedCornerShape(size * 0.28f)), + ) { + Icon(imageVector = icon, contentDescription = null, tint = tint, modifier = Modifier.size(size * 0.5f)) + } +} + +/** "‹ Back", for screens pushed over another (iOS gets this from its navigation bar). */ +@Composable +fun ProBackButton(onBack: () -> Unit, enabled: Boolean = true) { + val colors = proColors() + TextButton( + onClick = onBack, + enabled = enabled, + contentPadding = PaddingValues(start = 0.dp, end = 12.dp), + modifier = Modifier + .padding(bottom = 12.dp) + .semantics { contentDescription = "button - back" }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = null, + tint = colors.accent, + modifier = Modifier.size(22.dp), + ) + Text(text = "Back", fontSize = 15.sp, fontWeight = FontWeight.Medium, color = colors.accent) + } +} + +/** A full-width outlined button, for the sign-in providers. */ +@Composable +fun ProOutlineButton( + title: String, + onClick: () -> Unit, + contentDescription: String, + enabled: Boolean = true, + /** The provider mark or icon, drawn in a 20dp box before the title. */ + content: @Composable () -> Unit, +) { + val colors = proColors() + Surface( + onClick = onClick, + enabled = enabled, + color = colors.surface, + contentColor = colors.text, + shape = RoundedCornerShape(10.dp), + border = BorderStroke(1.dp, colors.border), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .semantics { this.contentDescription = contentDescription }, + ) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.heightIn(min = 48.dp), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(20.dp)) { content() } + Spacer(modifier = Modifier.width(10.dp)) + Text(text = title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + } + } +} + +/** A provider mark sized for [ProOutlineButton]; Apple's white mark is inked for the outlined button. */ +@Composable +fun ProProviderMark(painter: Painter, tintTo: Color? = null) { + Image( + painter = painter, + contentDescription = null, + colorFilter = tintTo?.let { androidx.compose.ui.graphics.ColorFilter.tint(it) }, + modifier = Modifier.size(20.dp), + ) +} + +/** A text-only action in the accent colour, 44dp tall. */ +@Composable +fun ProTextButton( + title: String, + onClick: () -> Unit, + contentDescription: String, + enabled: Boolean = true, + alignment: Alignment = Alignment.Center, +) { + val colors = proColors() + Box( + contentAlignment = alignment, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 44.dp) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .semantics { this.contentDescription = contentDescription }, + ) { + Text( + text = title, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + color = if (enabled) colors.accent else colors.accent.copy(alpha = 0.5f), + ) + } +} + +/** + * "Prompt? Action", centred, with only the action tappable. One row of two texts at the same size, + * so both sit on one baseline (a TextButton next to a Text would not). + */ +@Composable +fun ProPromptLink(prompt: String, action: String, onClick: () -> Unit, contentDescription: String) { + val colors = proColors() + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text(text = prompt, fontSize = 15.sp, color = colors.muted) + Spacer(modifier = Modifier.width(4.dp)) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .heightIn(min = 44.dp) + .clickable(role = Role.Button, onClick = onClick) + .semantics { this.contentDescription = contentDescription }, + ) { + Text(text = action, fontSize = 15.sp, fontWeight = FontWeight.Medium, color = colors.accent) + } + } +} + +/** A rule with a short label in the middle. */ +@Composable +fun ProDivider(label: String) { + val colors = proColors() + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Box(modifier = Modifier.weight(1f).height(1.dp).background(colors.hairline)) + Text(text = label, fontSize = 13.sp, color = colors.muted) + Box(modifier = Modifier.weight(1f).height(1.dp).background(colors.hairline)) + } +} + +/** A content slot's error message, if it has one. */ +@Composable +fun ProErrorText(message: String?) { + message ?: return + val colors = proColors() + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Top, + modifier = Modifier.semantics(mergeDescendants = true) { contentDescription = "error-message" }, + ) { + Icon( + imageVector = Icons.Outlined.ErrorOutline, + contentDescription = null, + tint = colors.danger, + modifier = Modifier.size(18.dp), + ) + Text(text = message, fontSize = 14.sp, color = colors.danger) + } +} + +/** A small uppercase label above a [ProGroup]. */ +@Composable +fun ProSectionLabel(text: String) { + Text( + text = text.uppercase(), + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + letterSpacing = 0.3.sp, + color = proColors().muted, + modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 8.dp), + ) +} + +/** Grouped rows on a bordered card, like a settings section. Separate rows with [ProRowSeparator]. */ +@Composable +fun ProGroup(content: @Composable ColumnScope.() -> Unit) { + val colors = proColors() + val shape = RoundedCornerShape(12.dp) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(shape) + .background(colors.surface) + .border(1.dp, colors.hairline, shape), + content = content, + ) +} + +/** The hairline between rows, inset past a row's icon. */ +@Composable +fun ProRowSeparator(inset: Dp = 48.dp) { + Box( + modifier = Modifier + .padding(start = inset) + .fillMaxWidth() + .height(1.dp) + .background(proColors().hairline), + ) +} + +/** One settings row: an optional icon, a title, an optional value and a chevron. */ +@Composable +fun ProRow( + title: String, + contentDescription: String, + onClick: (() -> Unit)?, + icon: ImageVector? = null, + value: String? = null, + valueTint: Color = proColors().muted, + tint: Color = proColors().text, + showsChevron: Boolean = true, + enabled: Boolean = true, +) { + val colors = proColors() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .then( + if (onClick != null) { + Modifier.clickable(enabled = enabled, role = Role.Button, onClick = onClick) + } else { + Modifier + }, + ) + .semantics(mergeDescendants = true) { this.contentDescription = contentDescription } + .heightIn(min = 52.dp) + .padding(horizontal = 16.dp), + ) { + icon?.let { Icon(imageVector = it, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp)) } + Text( + text = title, + fontSize = 16.sp, + fontWeight = if (icon == null) FontWeight.Medium else FontWeight.Normal, + color = tint, + modifier = Modifier.weight(1f), + ) + value?.let { Text(text = it, fontSize = 15.sp, color = valueTint) } + if (showsChevron) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = colors.chevron, + modifier = Modifier.size(20.dp), + ) + } + } +} + +/** A selectable row in a [ProGroup]: icon tile, title, description and a radio mark. */ +@Composable +fun ProChoiceRow( + icon: ImageVector, + title: String, + detail: String, + isSelected: Boolean, + onClick: () -> Unit, + contentDescription: String, +) { + val colors = proColors() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier + .fillMaxWidth() + .clickable(role = Role.RadioButton, onClick = onClick) + .semantics(mergeDescendants = true) { this.contentDescription = contentDescription } + .padding(horizontal = 16.dp, vertical = 14.dp), + ) { + ProIconTile( + icon = icon, + fill = if (isSelected) colors.accentSoft else colors.tile, + tint = if (isSelected) colors.accentSoftText else colors.text, + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(text = title, fontSize = 16.sp, fontWeight = FontWeight.Medium, color = colors.text) + Text(text = detail, fontSize = 14.sp, color = colors.muted) + } + RadioButton( + selected = isSelected, + onClick = null, + colors = RadioButtonDefaults.colors(selectedColor = colors.accent, unselectedColor = colors.border), + ) + } +} + +/** The terms line under the screens that sign in or create an account, from the configured URLs. */ +@Composable +fun ProLegalText(prefix: String = "By continuing, you agree to the") { + val links = LocalLegalLinks.current + val tos = links.tosUrl ?: return + val privacy = links.privacyPolicyUrl ?: return + val colors = proColors() + val linkStyle = TextLinkStyles(SpanStyle(color = colors.accent)) + Text( + text = buildAnnotatedString { + append("$prefix ") + withLink(LinkAnnotation.Url(tos, linkStyle)) { append("Terms of Service") } + append(" and ") + withLink(LinkAnnotation.Url(privacy, linkStyle)) { append("Privacy Policy") } + append(".") + }, + fontSize = 13.sp, + lineHeight = 18.sp, + color = colors.muted, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "terms" }, + ) +} + +/** The identity's first letter on a soft accent circle, or the person glyph when it has none. */ +@Composable +fun ProAvatar(identity: String) { + val letter = identity.firstOrNull { it.isLetter() }?.uppercase() + val colors = proColors() + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(56.dp) + .background(colors.accentSoft, CircleShape), + ) { + if (letter != null) { + Text(text = letter, fontSize = 22.sp, fontWeight = FontWeight.SemiBold, color = colors.accentSoftText) + } else { + Icon(imageVector = Icons.Outlined.Person, contentDescription = null, tint = colors.accentSoftText) + } + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/professional/ProPhoneScreens.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/professional/ProPhoneScreens.kt new file mode 100644 index 0000000000..eceadb14b0 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/professional/ProPhoneScreens.kt @@ -0,0 +1,168 @@ +package com.firebaseui.android.demo.auth.fullcustomization.professional + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Phone +import androidx.compose.material.icons.outlined.Pin +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.firebase.ui.auth.data.CountryData +import com.firebase.ui.auth.ui.components.CountrySelector +import com.firebase.ui.auth.ui.components.VerificationCodeInputField +import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState +import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton + +@Composable +fun ProPhoneEntry(state: PhoneAuthContentState) { + val isPhoneValid = remember(state.phoneNumber) { + android.util.Patterns.PHONE.matcher(state.phoneNumber).matches() + } + ProPage(header = { + ProHeader( + icon = Icons.Outlined.Phone, + title = "Sign in with your phone", + subtitle = "We'll text you a 6-digit code to confirm it's you. Message and data rates may apply.", + ) + }) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ProPhoneField( + phoneNumber = state.phoneNumber, + onPhoneNumberChange = state.onPhoneNumberChange, + selectedCountry = state.selectedCountry, + onCountrySelected = state.onCountrySelected, + enabled = !state.isLoading, + contentDescription = "text-field - phone number input", + ) + ProErrorText(state.error) + CtaButton( + text = "Send code", + onClick = state.onSendCodeClick, + enabled = isPhoneValid && !state.isLoading, + isLoading = state.isLoading, + modifier = Modifier.semantics { contentDescription = "button - send verification code" }, + ) + } + } +} + +@Composable +fun ProPhoneVerification(state: PhoneAuthContentState) { + ProPage(header = { + ProHeader( + icon = Icons.Outlined.Pin, + title = "Enter the code", + subtitle = "We sent a 6-digit code to ${state.fullPhoneNumber}.", + ) + }) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + VerificationCodeInputField( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "text-field - verification code input" }, + isError = state.error != null, + errorMessage = state.error, + onCodeChange = state.onVerificationCodeChange, + ) + CtaButton( + text = "Verify", + onClick = state.onVerifyCodeClick, + enabled = state.verificationCode.isNotBlank() && !state.isLoading, + isLoading = state.isLoading, + modifier = Modifier.semantics { contentDescription = "button - verify code" }, + ) + } + Spacer(modifier = Modifier.height(4.dp)) + ProTextButton( + title = if (state.resendTimer > 0) "Resend code in ${state.resendTimer}s" else "Resend code", + onClick = state.onResendCodeClick, + enabled = state.resendTimer == 0 && !state.isLoading, + contentDescription = "button - resend code", + ) + ProTextButton( + title = "Use a different number", + onClick = state.onChangeNumberClick, + enabled = !state.isLoading, + contentDescription = "button - use a different number", + ) + } +} + +/** The library's country picker and the number in one bordered box, split by a hairline. */ +@Composable +fun ProPhoneField( + phoneNumber: String, + onPhoneNumberChange: (String) -> Unit, + selectedCountry: CountryData?, + onCountrySelected: (CountryData) -> Unit, + enabled: Boolean, + contentDescription: String, +) { + val colors = proColors() + val shape = RoundedCornerShape(10.dp) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(text = "Phone number", fontSize = 14.sp, fontWeight = FontWeight.Medium, color = colors.text) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .background(colors.field, shape) + .border(1.dp, colors.border, shape) + .padding(start = 4.dp, end = 14.dp), + ) { + selectedCountry?.let { country -> + Box(modifier = Modifier.semantics { this.contentDescription = "country code selector" }) { + CountrySelector( + selectedCountry = country, + onCountrySelected = onCountrySelected, + enabled = enabled, + ) + } + Box(modifier = Modifier.width(1.dp).height(24.dp).background(colors.hairline)) + } + Spacer(modifier = Modifier.width(10.dp)) + BasicTextField( + value = phoneNumber, + onValueChange = onPhoneNumberChange, + enabled = enabled, + singleLine = true, + textStyle = TextStyle(fontSize = 16.sp, color = colors.text), + cursorBrush = SolidColor(colors.accent), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + modifier = Modifier + .weight(1f) + .semantics { this.contentDescription = contentDescription }, + decorationBox = { inner -> + if (phoneNumber.isEmpty()) { + Text(text = "555 123 4567", fontSize = 16.sp, color = colors.placeholder) + } + inner() + }, + ) + } + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthMethodPickerUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthMethodPickerUI.kt index 9592a31203..f9dd85fe11 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthMethodPickerUI.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthMethodPickerUI.kt @@ -30,6 +30,9 @@ import com.firebase.ui.auth.ui.screens.AuthRoute import com.firebase.ui.auth.ui.screens.email.EmailAuthMode import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen import com.firebaseui.android.demo.auth.fullcustomization.common.OtherSignInMethodsSheet +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProSignIn +import com.firebaseui.android.demo.auth.fullcustomization.professional.isProfessional +import com.firebaseui.android.demo.auth.fullcustomization.professional.proInlineProviders import com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages.EmailEntryStep import com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages.LoginStep import com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages.SignUpStep @@ -111,6 +114,8 @@ fun AuthMethodPickerUI( ) { var showOtherMethods by remember { mutableStateOf(false) } val backStack = rememberNavBackStack(EmailEntryKey) + // Professional shows the common providers on its sign-in screen; the sheet keeps the rest. + val inlineProviders = if (isProfessional()) proInlineProviders(otherProviders) else emptyList() Box(modifier = Modifier.fillMaxSize()) { NavDisplay( @@ -120,6 +125,32 @@ fun AuthMethodPickerUI( predictivePopTransitionSpec = EmailStepPredictivePopTransform, entryProvider = entryProvider { entry { + // Professional opens on sign-in itself, email and password together, as the + // iOS demo does; the other modes push from it like the shared steps below. + if (isProfessional()) { + EmailAuthScreen( + context = context, + configuration = configuration, + authUI = authUI, + mode = EmailAuthMode.SignIn, + onNavigateToMode = { mode, email -> backStack.navigateToStep(stepFor(mode, email)) }, + onSuccess = onSuccess, + onError = onError, + onCancel = onCancel, + ) { state -> + ProSignIn( + state = state, + isEmailLinkOffered = configuration.providers + .filterIsInstance() + .firstOrNull()?.isEmailLinkSignInEnabled == true, + inlineProviders = inlineProviders, + onProviderSelected = onProviderSelected, + onShowOtherMethods = { showOtherMethods = true } + .takeIf { otherProviders.size > inlineProviders.size }, + ) + } + return@entry + } // No auth operation here, so there is nothing for EmailAuthContentState to own. var email by rememberSaveable { mutableStateOf("") } EmailEntryStep( @@ -154,7 +185,7 @@ fun AuthMethodPickerUI( if (showOtherMethods) { OtherSignInMethodsSheet( - otherProviders = otherProviders, + otherProviders = otherProviders - inlineProviders.toSet(), onProviderSelected = onProviderSelected, onDismissRequest = { showOtherMethods = false }, tosUrl = configuration.tosUrl, diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthenticatedUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthenticatedUI.kt index 8fc00b68c3..1395c4f58e 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthenticatedUI.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthenticatedUI.kt @@ -1,13 +1,19 @@ package com.firebaseui.android.demo.auth.fullcustomization.screens import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.ButtonDefaults +import androidx.compose.material.icons.outlined.Email +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material.icons.outlined.Security import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -18,14 +24,19 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.lifecycleScope import com.firebase.ui.auth.AuthException @@ -37,6 +48,16 @@ import com.firebaseui.android.demo.R import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField +import com.firebaseui.android.demo.auth.fullcustomization.common.secondaryCtaColors +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProAvatar +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProGroup +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProHeader +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProPage +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProRow +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProRowSeparator +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProSectionLabel +import com.firebaseui.android.demo.auth.fullcustomization.professional.isProfessional +import com.firebaseui.android.demo.auth.fullcustomization.professional.proColors import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await @@ -81,6 +102,81 @@ private fun SignedInPage(uiContext: AuthSuccessUiContext) { var statusMessage by remember { mutableStateOf(null) } var isError by remember { mutableStateOf(false) } + val changePassword: () -> Unit = { + // lifecycleScope: the reauth overlay replaces this screen; the retry outlives it. + lifecycleOwner.lifecycleScope.launch { + isUpdating = true + statusMessage = null + isError = false + try { + authUI.withReauth( + context, + reason = "Verify your identity to change your password", + ) { + authUI.getCurrentUser()?.updatePassword(newPassword)?.await() + Log.d(TAG, "Password changed successfully") + } + } catch (e: AuthException.AuthCancelledException) { + // Declined, not failed: the password is unchanged, so report neither. + Log.d(TAG, "Reauthentication declined", e) + } catch (e: Exception) { + Log.e(TAG, "Password change failed", e) + isError = true + statusMessage = "Couldn't change the password. Try again." + } finally { + isUpdating = false + } + } + } + + val passwordField: @Composable () -> Unit = { + FullCustomizationTextField( + value = newPassword, + onValueChange = { + newPassword = it + statusMessage = null + }, + label = "New password", + enabled = !isUpdating, + isError = isError, + supportingText = statusMessage, + visualTransformation = if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) { + Icons.Default.VisibilityOff + } else { + Icons.Default.Visibility + }, + contentDescription = null, + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "text-field - new password secure input" }, + ) + } + + if (isProfessional()) { + ProAccountPage( + uiContext = uiContext, + identifier = identifier, + hasTwoFactor = enrolledFactors.isNotEmpty(), + canChangePassword = newPassword.length >= 6 && !isUpdating, + isUpdating = isUpdating, + onChangePassword = changePassword, + ) { + passwordField() + } + return + } + AuthPage( mascot = R.drawable.full_customization_mascot, mascotDescription = "doggo - cute welcome mascot", @@ -100,67 +196,12 @@ private fun SignedInPage(uiContext: AuthSuccessUiContext) { color = MaterialTheme.colorScheme.onSurfaceVariant, ) - FullCustomizationTextField( - value = newPassword, - onValueChange = { - newPassword = it - statusMessage = null - }, - label = "New password", - enabled = !isUpdating, - isError = isError, - supportingText = statusMessage, - visualTransformation = if (passwordVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - trailingIcon = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon( - imageVector = if (passwordVisible) { - Icons.Default.VisibilityOff - } else { - Icons.Default.Visibility - }, - contentDescription = null, - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .semantics { contentDescription = "text-field - new password secure input" }, - ) + passwordField() }, actions = { CtaButton( text = "Change password", - onClick = { - // lifecycleScope: the reauth overlay replaces this screen; the retry outlives it. - lifecycleOwner.lifecycleScope.launch { - isUpdating = true - statusMessage = null - isError = false - try { - authUI.withReauth( - context, - reason = "Verify your identity to change your password", - ) { - authUI.getCurrentUser()?.updatePassword(newPassword)?.await() - Log.d(TAG, "Password changed successfully") - } - } catch (e: AuthException.AuthCancelledException) { - // Declined, not failed: the password is unchanged, so report neither. - Log.d(TAG, "Reauthentication declined", e) - } catch (e: Exception) { - Log.e(TAG, "Password change failed", e) - isError = true - statusMessage = "Couldn't change the password. Try again." - } finally { - isUpdating = false - } - } - }, + onClick = changePassword, enabled = newPassword.length >= 6 && !isUpdating, isLoading = isUpdating, modifier = Modifier.semantics { contentDescription = "button - change password" }, @@ -173,10 +214,7 @@ private fun SignedInPage(uiContext: AuthSuccessUiContext) { text = if (enrolledFactors.isEmpty()) "Set up two-factor" else "Manage two-factor", onClick = uiContext.onManageMfa, enabled = !isUpdating, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), + colors = secondaryCtaColors(), modifier = Modifier.semantics { contentDescription = "button - manage mfa" }, ) @@ -225,10 +263,7 @@ private fun VerifyEmailPage(uiContext: AuthSuccessUiContext) { CtaButton( text = stringProvider.resendVerificationEmailAction, onClick = { user?.sendEmailVerification() }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), + colors = secondaryCtaColors(), modifier = Modifier.semantics { contentDescription = "button - resend verification email" }, @@ -285,3 +320,124 @@ private fun ProfileCompletionPage( }, ) } + +/** + * The professional account screen, as the iOS demo draws it: a profile line, a Security section of + * settings rows, and sign-out on its own group. The password row opens the change form in place. + */ +@Composable +private fun ProAccountPage( + uiContext: AuthSuccessUiContext, + identifier: String, + hasTwoFactor: Boolean, + canChangePassword: Boolean, + isUpdating: Boolean, + onChangePassword: () -> Unit, + /** The new-password field, shown when the password row is opened. */ + content: @Composable () -> Unit, +) { + val colors = proColors() + val user = uiContext.authUI.getCurrentUser() + val providerIds = user?.providerData?.map { it.providerId }.orEmpty().filter { it != "firebase" } + // Only accounts that sign in with a password can change one. + val hasPassword = "password" in providerIds + val signInMethod = when (providerIds.firstOrNull()) { + "password" -> "email" + "phone" -> "phone" + "google.com" -> "Google" + "apple.com" -> "Apple" + "github.com" -> "GitHub" + null -> "a temporary account" + else -> providerIds.first() + } + val email = user?.email?.takeIf { it.isNotBlank() } + var showPasswordForm by rememberSaveable { mutableStateOf(false) } + + ProPage( + modifier = Modifier.semantics { contentDescription = "authenticated - account card" }, + header = { ProHeader(title = "Account") }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + modifier = Modifier.semantics(mergeDescendants = true) { contentDescription = "profile" }, + ) { + ProAvatar(identity = identifier) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = identifier.ifBlank { "Signed in" }, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = colors.text, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + ) + Text(text = "Signed in with $signInMethod", fontSize = 14.sp, color = colors.muted) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + ProSectionLabel(text = "Security") + ProGroup { + if (hasPassword) { + ProRow( + icon = Icons.Outlined.Key, + title = "Password", + value = if (showPasswordForm) "Cancel" else "Change", + contentDescription = "button - password row", + onClick = { showPasswordForm = !showPasswordForm }, + enabled = !isUpdating, + ) + if (showPasswordForm) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp), + ) { + content() + CtaButton( + text = "Update password", + onClick = onChangePassword, + enabled = canChangePassword, + isLoading = isUpdating, + modifier = Modifier.semantics { contentDescription = "button - change password" }, + ) + } + } + ProRowSeparator() + } + ProRow( + icon = Icons.Outlined.Security, + title = "Two-factor authentication", + value = if (hasTwoFactor) "On" else "Off", + contentDescription = "button - manage mfa", + onClick = uiContext.onManageMfa, + enabled = !isUpdating, + ) + if (email != null) { + ProRowSeparator() + val verified = user?.isEmailVerified == true + ProRow( + icon = Icons.Outlined.Email, + title = "Email", + value = if (verified) "Verified" else "Verify", + valueTint = if (verified) colors.muted else colors.accent, + showsChevron = false, + contentDescription = "button - verify email", + onClick = if (verified) null else ({ user?.sendEmailVerification(); Unit }), + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + ProGroup { + ProRow( + title = uiContext.stringProvider.signOutAction, + tint = colors.accent, + showsChevron = false, + contentDescription = "button - sign out", + onClick = uiContext.onSignOut, + enabled = !isUpdating, + ) + } + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/EmailAuthUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/EmailAuthUI.kt index 06bcecbbe4..4c61d4e98c 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/EmailAuthUI.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/EmailAuthUI.kt @@ -1,6 +1,5 @@ package com.firebaseui.android.demo.auth.fullcustomization.screens.email -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -9,11 +8,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState import com.firebase.ui.auth.ui.screens.email.EmailAuthMode -import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.common.ThemeBackground +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProEmailLink +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProResetPassword +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProSignIn +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProSignUp +import com.firebaseui.android.demo.auth.fullcustomization.professional.isProfessional import com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages.EmailEntryStep import com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages.LoginStep import com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages.SignUpStep @@ -38,6 +40,16 @@ fun EmailAuthUI(state: EmailAuthContentState) { return } + if (isProfessional()) { + when (state.mode) { + EmailAuthMode.SignUp -> ProSignUp(state, onBack = state.onGoToSignIn) + EmailAuthMode.ResetPassword -> ProResetPassword(state, onBack = state.onGoToSignIn) + EmailAuthMode.EmailLinkSignIn -> ProEmailLink(state, onBack = state.onGoToSignIn) + EmailAuthMode.SignIn -> ProSignIn(state, isEmailLinkOffered = false) + } + return + } + var chosen by rememberSaveable { mutableStateOf(state.email.isNotBlank()) } val onUseDifferentEmail: () -> Unit = { @@ -48,12 +60,7 @@ fun EmailAuthUI(state: EmailAuthContentState) { Box(modifier = Modifier.fillMaxSize()) { // The email pages don't paint their own background, so this slot must. - Image( - painter = painterResource(id = R.drawable.custom_background), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) + ThemeBackground() if (!chosen) { EmailEntryStep( diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/EmailEntryStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/EmailEntryStep.kt index 4b8945ffb8..b93b646541 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/EmailEntryStep.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/EmailEntryStep.kt @@ -15,11 +15,11 @@ import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Login import androidx.compose.material3.Icon -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -42,7 +42,83 @@ import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton import com.firebaseui.android.demo.auth.fullcustomization.common.EmailFieldIcon import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField import com.firebaseui.android.demo.auth.fullcustomization.common.HardOffsetShadow -import com.firebaseui.android.demo.auth.fullcustomization.theme.IntroShape +import com.firebaseui.android.demo.auth.fullcustomization.common.SparkHeadline +import com.firebaseui.android.demo.auth.fullcustomization.common.secondaryCtaColors +import com.firebaseui.android.demo.auth.fullcustomization.theme.FullCustomizationThemeId +import com.firebaseui.android.demo.auth.fullcustomization.theme.LocalFullCustomizationStyle + +private val IntroShape = RoundedCornerShape(80.dp) + +/** + * Doggo's mascot over the welcome bubble. The iOS frames have no bubble, so Grid sets the greeting + * as its title and Coffee Spark shows its headline pill. + */ +@Composable +private fun LandingHeadline() { + val style = LocalFullCustomizationStyle.current + when (style.id) { + FullCustomizationThemeId.Doggo -> { + Image( + painter = painterResource(id = R.drawable.full_customization_mascot), + contentDescription = "doggo - cute welcome mascot", + modifier = Modifier + .size(96.dp) + .offset(y = 12.dp) + .zIndex(1f), + ) + + // An illustration: it keeps Figma's blue in both modes, like the mascot above it. + Surface( + color = Color(0xFF4C8BFF), + shape = IntroShape, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "intro - welcome headline bubble" }, + ) { + Text( + text = "Hey there,\nWelcome", + style = MaterialTheme.typography.headlineMedium.copy( + textAlign = TextAlign.Center, + brush = Brush.radialGradient( + colors = listOf( + Color(0xFFFFF8F8), + Color(0xFFFFDDB4), + Color(0xFFFFD8EB), + ), + ), + ), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 16.dp), + ) + } + } + FullCustomizationThemeId.Grid -> { + Text( + text = "Hey there,\nWelcome", + style = MaterialTheme.typography.headlineMedium, + color = style.titleColor, + textAlign = TextAlign.Center, + modifier = Modifier.semantics { contentDescription = "title - welcome" }, + ) + Spacer(modifier = Modifier.height(24.dp)) + } + FullCustomizationThemeId.Spark -> { + SparkHeadline() + Spacer(modifier = Modifier.height(24.dp)) + } + FullCustomizationThemeId.Professional -> { + Text( + text = "Welcome back", + style = MaterialTheme.typography.headlineSmall, + color = style.titleColor, + textAlign = TextAlign.Center, + modifier = Modifier.semantics { contentDescription = "title - welcome" }, + ) + Spacer(modifier = Modifier.height(24.dp)) + } + } +} @Composable fun EmailEntryStep( @@ -77,39 +153,7 @@ fun EmailEntryStep( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { - Image( - painter = painterResource(id = R.drawable.full_customization_mascot), - contentDescription = "doggo - cute welcome mascot", - modifier = Modifier - .size(96.dp) - .offset(y = 12.dp) - .zIndex(1f), - ) - - Surface( - color = MaterialTheme.colorScheme.secondary, - shape = IntroShape, - modifier = Modifier - .fillMaxWidth() - .semantics { contentDescription = "intro - welcome headline bubble" }, - ) { - Text( - text = "Hey there,\nWelcome", - style = MaterialTheme.typography.headlineMedium.copy( - textAlign = TextAlign.Center, - brush = Brush.radialGradient( - colors = listOf( - Color(0xFFFFF8F8), - Color(0xFFFFDDB4), - Color(0xFFFFD8EB), - ), - ), - ), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 16.dp), - ) - } + LandingHeadline() HardOffsetShadow( shape = AuthFieldShape, @@ -119,7 +163,7 @@ fun EmailEntryStep( modifier = Modifier .fillMaxWidth() .semantics { contentDescription = "email - sign in card" }, - color = MaterialTheme.colorScheme.surface, + color = LocalFullCustomizationStyle.current.cardFill, shape = AuthFieldShape, ) { Column( @@ -168,10 +212,7 @@ fun EmailEntryStep( onClick = onCreateAccount, // Not gated on the address: the sign-up form collects and confirms it. enabled = !isLoading, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), + colors = secondaryCtaColors(), modifier = Modifier.semantics { contentDescription = "button - create account" }, ) } diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/LoginStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/LoginStep.kt index 769e3063af..e8f079714b 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/LoginStep.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/LoginStep.kt @@ -1,6 +1,5 @@ package com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints @@ -12,13 +11,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -31,7 +28,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -40,18 +36,35 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState +import com.firebase.ui.auth.ui.screens.email.EmailAuthMode import com.firebaseui.android.demo.R import com.firebaseui.android.demo.auth.fullcustomization.common.AuthFieldShape import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton import com.firebaseui.android.demo.auth.fullcustomization.common.EmailFieldIcon import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField import com.firebaseui.android.demo.auth.fullcustomization.common.HardOffsetShadow +import com.firebaseui.android.demo.auth.fullcustomization.common.ThemeHeader +import com.firebaseui.android.demo.auth.fullcustomization.common.secondaryCtaColors +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProEmailLink +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProResetPassword +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProSignIn +import com.firebaseui.android.demo.auth.fullcustomization.professional.isProfessional +import com.firebaseui.android.demo.auth.fullcustomization.theme.LocalFullCustomizationStyle @Composable fun LoginStep( state: EmailAuthContentState, onUseDifferentEmail: () -> Unit, ) { + if (isProfessional()) { + when (state.mode) { + EmailAuthMode.ResetPassword -> ProResetPassword(state, onBack = onUseDifferentEmail) + EmailAuthMode.EmailLinkSignIn -> ProEmailLink(state, onBack = onUseDifferentEmail) + else -> ProSignIn(state, isEmailLinkOffered = false, onBack = onUseDifferentEmail) + } + return + } + var passwordVisible by remember { mutableStateOf(false) } // heightIn(min = maxHeight) centres content when it fits and plain-scrolls when it doesn't. @@ -70,16 +83,10 @@ fun LoginStep( Spacer(modifier = Modifier.weight(1f)) Column(modifier = Modifier.fillMaxWidth()) { - Image( - painter = painterResource(id = R.drawable.full_customization_mascot), - contentDescription = "doggo - cute welcome mascot", - modifier = Modifier.size(72.dp), - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Login", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.primary, + ThemeHeader( + title = "Login", + mascot = R.drawable.full_customization_mascot, + mascotDescription = "doggo - cute welcome mascot", ) Spacer(modifier = Modifier.height(24.dp)) @@ -88,7 +95,7 @@ fun LoginStep( modifier = Modifier .fillMaxWidth() .semantics { contentDescription = "email - login card" }, - color = MaterialTheme.colorScheme.surface, + color = LocalFullCustomizationStyle.current.cardFill, shape = AuthFieldShape, ) { Column( @@ -167,10 +174,7 @@ fun LoginStep( text = if (state.emailSignInLinkSent) "Login link sent!" else "Send login link", onClick = state.onSignInEmailLinkClick, enabled = !state.isLoading, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), + colors = secondaryCtaColors(), modifier = Modifier.semantics { contentDescription = "button - send login link" }, ) diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/SignUpStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/SignUpStep.kt index 7f7dd2dff5..e44df6448a 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/SignUpStep.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/SignUpStep.kt @@ -1,6 +1,5 @@ package com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -12,12 +11,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -27,7 +24,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardType @@ -40,6 +36,10 @@ import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton import com.firebaseui.android.demo.auth.fullcustomization.common.EmailFieldIcon import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField import com.firebaseui.android.demo.auth.fullcustomization.common.HardOffsetShadow +import com.firebaseui.android.demo.auth.fullcustomization.common.ThemeHeader +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProSignUp +import com.firebaseui.android.demo.auth.fullcustomization.professional.isProfessional +import com.firebaseui.android.demo.auth.fullcustomization.theme.LocalFullCustomizationStyle private val NameFieldStartShape = RoundedCornerShape( topStart = 16.dp, @@ -59,6 +59,11 @@ fun SignUpStep( state: EmailAuthContentState, onUseDifferentEmail: () -> Unit, ) { + if (isProfessional()) { + ProSignUp(state, onBack = onUseDifferentEmail) + return + } + var firstName by remember { mutableStateOf("") } var lastName by remember { mutableStateOf("") } var confirmEmail by remember { mutableStateOf("") } @@ -90,16 +95,10 @@ fun SignUpStep( Spacer(modifier = Modifier.weight(1f)) Column(modifier = Modifier.fillMaxWidth()) { - Image( - painter = painterResource(id = R.drawable.full_customization_mascot), - contentDescription = "doggo - cute welcome mascot", - modifier = Modifier.size(72.dp), - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Sign up", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.primary, + ThemeHeader( + title = "Sign up", + mascot = R.drawable.full_customization_mascot, + mascotDescription = "doggo - cute welcome mascot", ) Spacer(modifier = Modifier.height(24.dp)) @@ -108,7 +107,7 @@ fun SignUpStep( modifier = Modifier .fillMaxWidth() .semantics { contentDescription = "sign up card" }, - color = MaterialTheme.colorScheme.surface, + color = LocalFullCustomizationStyle.current.cardFill, shape = AuthFieldShape, ) { Column( diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureSmsStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureSmsStep.kt index d73284fb9c..51a07b84fe 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureSmsStep.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureSmsStep.kt @@ -1,7 +1,7 @@ package com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Phone +import androidx.compose.material.icons.outlined.Sms import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -25,13 +26,25 @@ import androidx.compose.ui.unit.dp import com.firebase.ui.auth.mfa.MfaEnrollmentContentState import com.firebase.ui.auth.ui.components.CountrySelector import com.firebaseui.android.demo.R -import com.firebaseui.android.demo.auth.fullcustomization.common.AuthFieldShape import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField +import com.firebaseui.android.demo.auth.fullcustomization.common.themeFieldBorder +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProErrorText +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProHeader +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProPage +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProPhoneField +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProTextButton +import com.firebaseui.android.demo.auth.fullcustomization.professional.isProfessional +import com.firebaseui.android.demo.auth.fullcustomization.theme.LocalFullCustomizationStyle @Composable fun ConfigureSmsStep(state: MfaEnrollmentContentState) { + if (isProfessional()) { + ProConfigureSms(state) + return + } + AuthPage( mascot = R.drawable.full_customization_phone_mascot, mascotDescription = "doggo - cute phone sign-in mascot", @@ -45,15 +58,11 @@ fun ConfigureSmsStep(state: MfaEnrollmentContentState) { // CountrySelector needs a non-null country, so skip the step while it resolves. state.selectedCountry?.let { country -> Surface( - color = Color.White, - shape = AuthFieldShape, + color = LocalFullCustomizationStyle.current.fieldContainer, + shape = LocalFullCustomizationStyle.current.fieldShape, modifier = Modifier .requiredHeight(56.dp) - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant, - shape = AuthFieldShape, - ) + .themeFieldBorder() .semantics { contentDescription = "country code selector" }, ) { CountrySelector( @@ -121,3 +130,42 @@ fun ConfigureSmsStep(state: MfaEnrollmentContentState) { }, ) } + +/** The professional SMS step: the phone sign-in screen's combined field, as the iOS demo uses. */ +@Composable +private fun ProConfigureSms(state: MfaEnrollmentContentState) { + ProPage(header = { + ProHeader( + icon = Icons.Outlined.Sms, + title = "Add your phone number", + subtitle = "We'll text a code to this number whenever you sign in.", + ) + }) { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + ProPhoneField( + phoneNumber = state.phoneNumber, + onPhoneNumberChange = state.onPhoneNumberChange, + selectedCountry = state.selectedCountry, + onCountrySelected = state.onCountrySelected, + enabled = !state.isLoading, + contentDescription = "text-field - mfa phone number input", + ) + ProErrorText(state.error) + CtaButton( + text = "Send code", + onClick = state.onSendSmsCodeClick, + enabled = state.isValid && !state.isLoading, + isLoading = state.isLoading, + modifier = Modifier.semantics { contentDescription = "button - send mfa sms code" }, + ) + } + if (state.canGoBack) { + ProTextButton( + title = "Pick a different method", + onClick = state.onBackClick, + enabled = !state.isLoading, + contentDescription = "button - pick a different method", + ) + } + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureTotpStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureTotpStep.kt index 562fe7919a..4ead363834 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureTotpStep.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureTotpStep.kt @@ -1,6 +1,5 @@ package com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -19,7 +18,7 @@ import androidx.compose.ui.unit.dp import com.firebase.ui.auth.mfa.MfaEnrollmentContentState import com.firebase.ui.auth.ui.components.QrCodeImage import com.firebaseui.android.demo.R -import com.firebaseui.android.demo.auth.fullcustomization.common.AuthFieldShape +import com.firebaseui.android.demo.auth.fullcustomization.common.themeFieldBorder import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton @@ -43,11 +42,7 @@ fun ConfigureTotpStep(state: MfaEnrollmentContentState) { size = 200.dp, modifier = Modifier .align(Alignment.CenterHorizontally) - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant, - shape = AuthFieldShape, - ) + .themeFieldBorder() .padding(12.dp) .semantics { contentDescription = "mfa - totp qr code" }, ) diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/SelectFactorStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/SelectFactorStep.kt index 70995a799e..b06814bcdf 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/SelectFactorStep.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/SelectFactorStep.kt @@ -6,12 +6,19 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.material3.ButtonDefaults +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.PhoneAndroid +import androidx.compose.material.icons.outlined.Security +import androidx.compose.material.icons.outlined.Sms import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.contentDescription @@ -22,12 +29,30 @@ import com.firebase.ui.auth.mfa.MfaEnrollmentContentState import com.firebaseui.android.demo.R import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton +import com.firebaseui.android.demo.auth.fullcustomization.common.primaryCtaColors +import com.firebaseui.android.demo.auth.fullcustomization.common.secondaryCtaColors +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProChoiceRow +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProErrorText +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProGroup +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProHeader +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProPage +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProRow +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProRowSeparator +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProSectionLabel +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProTextButton +import com.firebaseui.android.demo.auth.fullcustomization.professional.isProfessional +import com.firebaseui.android.demo.auth.fullcustomization.professional.proColors import com.google.firebase.auth.MultiFactorInfo import com.google.firebase.auth.PhoneMultiFactorGenerator import com.google.firebase.auth.TotpMultiFactorGenerator @Composable fun SelectFactorStep(state: MfaEnrollmentContentState) { + if (isProfessional()) { + ProSelectFactor(state) + return + } + AuthPage( mascot = R.drawable.full_customization_mascot, mascotDescription = "doggo - cute security mascot", @@ -94,12 +119,9 @@ fun SelectFactorStep(state: MfaEnrollmentContentState) { enabled = !state.isLoading, // First factor takes the primary CTA colour; the rest read as alternatives. colors = if (index == 0) { - ButtonDefaults.buttonColors() + primaryCtaColors() } else { - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) + secondaryCtaColors() }, modifier = Modifier.semantics { contentDescription = "button - enroll ${factorCtaLabel(factor)}" @@ -122,6 +144,84 @@ fun SelectFactorStep(state: MfaEnrollmentContentState) { ) } +/** + * The professional method choice, as the iOS demo draws it: the account's methods with Remove, + * then the available ones as radio rows and one Continue. + */ +@Composable +private fun ProSelectFactor(state: MfaEnrollmentContentState) { + val colors = proColors() + var selected by remember(state.availableFactors) { mutableStateOf(state.availableFactors.firstOrNull()) } + + ProPage(header = { + ProHeader( + icon = Icons.Outlined.Security, + title = "Set up two-factor authentication", + subtitle = "Add a second step to sign-in, so a password on its own isn't enough.", + ) + }) { + if (state.enrolledFactors.isNotEmpty()) { + ProSectionLabel(text = "Your methods") + ProGroup { + state.enrolledFactors.forEachIndexed { index, info -> + if (index > 0) ProRowSeparator(inset = 16.dp) + ProRow( + title = enrolledFactorLabel(info), + value = "Remove", + valueTint = colors.danger, + showsChevron = false, + contentDescription = "button - remove factor ${enrolledFactorLabel(info)}", + onClick = { state.onUnenrollFactor(info) }, + enabled = !state.isLoading, + ) + } + } + Spacer(modifier = Modifier.height(24.dp)) + } + + if (state.availableFactors.isNotEmpty()) { + ProSectionLabel(text = "Choose a method") + ProGroup { + state.availableFactors.forEachIndexed { index, factor -> + if (index > 0) ProRowSeparator(inset = 66.dp) + ProChoiceRow( + icon = if (factor == MfaFactor.Sms) Icons.Outlined.Sms else Icons.Outlined.PhoneAndroid, + title = if (factor == MfaFactor.Sms) "Text message" else "Authenticator app", + detail = if (factor == MfaFactor.Sms) { + "We'll text a code to your phone." + } else { + "Use a code from an app like Google Authenticator." + }, + isSelected = selected == factor, + onClick = { selected = factor }, + contentDescription = "button - choose ${factorCtaLabel(factor)}", + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + ProErrorText(state.error) + Spacer(modifier = Modifier.height(12.dp)) + if (state.availableFactors.isNotEmpty()) { + CtaButton( + text = "Continue", + onClick = { selected?.let(state.onFactorSelected) }, + enabled = selected != null && !state.isLoading, + isLoading = state.isLoading, + modifier = Modifier.semantics { contentDescription = "button - start enrollment" }, + ) + } + state.onSkipClick?.let { onSkip -> + ProTextButton( + title = "Not now", + onClick = onSkip, + enabled = !state.isLoading, + contentDescription = "button - not now", + ) + } + } +} + private fun factorCtaLabel(factor: MfaFactor): String = when (factor) { MfaFactor.Sms -> "Use text message" MfaFactor.Totp -> "Use an authenticator app" diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/PhoneSignInUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/PhoneSignInUI.kt index ecf4bc41a1..ac56c216e1 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/PhoneSignInUI.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/PhoneSignInUI.kt @@ -1,27 +1,19 @@ package com.firebaseui.android.demo.auth.fullcustomization.screens.phone -import androidx.compose.foundation.Image 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.layout.ContentScale -import androidx.compose.ui.res.painterResource import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState import com.firebase.ui.auth.ui.screens.phone.PhoneAuthStep -import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.common.ThemeBackground import com.firebaseui.android.demo.auth.fullcustomization.screens.phone.pages.PhoneEntryStep import com.firebaseui.android.demo.auth.fullcustomization.screens.phone.pages.PhoneVerificationStep @Composable fun PhoneSignInUI(state: PhoneAuthContentState) { Box(modifier = Modifier.fillMaxSize()) { - Image( - painter = painterResource(id = R.drawable.custom_background), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) + ThemeBackground() when (state.step) { PhoneAuthStep.EnterPhoneNumber -> PhoneEntryStep(state) PhoneAuthStep.EnterVerificationCode -> PhoneVerificationStep(state) diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneEntryStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneEntryStep.kt index 87e7d8ed45..34e499af44 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneEntryStep.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneEntryStep.kt @@ -1,7 +1,5 @@ package com.firebaseui.android.demo.auth.fullcustomization.screens.phone.pages -import androidx.compose.foundation.Image -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -14,7 +12,6 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -28,7 +25,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.KeyboardType @@ -40,9 +36,19 @@ import com.firebaseui.android.demo.auth.fullcustomization.common.AuthFieldShape import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField import com.firebaseui.android.demo.auth.fullcustomization.common.HardOffsetShadow +import com.firebaseui.android.demo.auth.fullcustomization.common.ThemeHeader +import com.firebaseui.android.demo.auth.fullcustomization.common.themeFieldBorder +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProPhoneEntry +import com.firebaseui.android.demo.auth.fullcustomization.professional.isProfessional +import com.firebaseui.android.demo.auth.fullcustomization.theme.LocalFullCustomizationStyle @Composable fun PhoneEntryStep(state: PhoneAuthContentState) { + if (isProfessional()) { + ProPhoneEntry(state) + return + } + val isPhoneValid = remember(state.phoneNumber) { android.util.Patterns.PHONE.matcher(state.phoneNumber).matches() } @@ -63,16 +69,10 @@ fun PhoneEntryStep(state: PhoneAuthContentState) { Spacer(modifier = Modifier.weight(1f)) Column(modifier = Modifier.fillMaxWidth()) { - Image( - painter = painterResource(id = R.drawable.full_customization_phone_mascot), - contentDescription = "doggo - cute phone sign-in mascot", - modifier = Modifier.size(72.dp), - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Login by phone number", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.primary, + ThemeHeader( + title = "Login by phone number", + mascot = R.drawable.full_customization_phone_mascot, + mascotDescription = "doggo - cute phone sign-in mascot", ) Spacer(modifier = Modifier.height(24.dp)) @@ -81,7 +81,7 @@ fun PhoneEntryStep(state: PhoneAuthContentState) { modifier = Modifier .fillMaxWidth() .semantics { contentDescription = "phone - sign in card" }, - color = MaterialTheme.colorScheme.surface, + color = LocalFullCustomizationStyle.current.cardFill, shape = AuthFieldShape, ) { Column( @@ -93,15 +93,11 @@ fun PhoneEntryStep(state: PhoneAuthContentState) { modifier = Modifier.fillMaxWidth(), ) { Surface( - color = Color.White, - shape = AuthFieldShape, + color = LocalFullCustomizationStyle.current.fieldContainer, + shape = LocalFullCustomizationStyle.current.fieldShape, modifier = Modifier .requiredHeight(56.dp) - .border( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant, - shape = AuthFieldShape, - ) + .themeFieldBorder() .semantics { contentDescription = "country code selector" }, ) { CountrySelector( diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneVerificationStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneVerificationStep.kt index 8e9a153182..00564e40b7 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneVerificationStep.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneVerificationStep.kt @@ -1,6 +1,5 @@ package com.firebaseui.android.demo.auth.fullcustomization.screens.phone.pages -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.BoxWithConstraints @@ -12,7 +11,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme @@ -21,7 +19,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign @@ -32,9 +29,18 @@ import com.firebaseui.android.demo.R import com.firebaseui.android.demo.auth.fullcustomization.common.AuthFieldShape import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton import com.firebaseui.android.demo.auth.fullcustomization.common.HardOffsetShadow +import com.firebaseui.android.demo.auth.fullcustomization.common.ThemeHeader +import com.firebaseui.android.demo.auth.fullcustomization.professional.ProPhoneVerification +import com.firebaseui.android.demo.auth.fullcustomization.professional.isProfessional +import com.firebaseui.android.demo.auth.fullcustomization.theme.LocalFullCustomizationStyle @Composable fun PhoneVerificationStep(state: PhoneAuthContentState) { + if (isProfessional()) { + ProPhoneVerification(state) + return + } + BoxWithConstraints( modifier = Modifier .fillMaxSize() @@ -50,16 +56,10 @@ fun PhoneVerificationStep(state: PhoneAuthContentState) { Spacer(modifier = Modifier.weight(1f)) Column(modifier = Modifier.fillMaxWidth()) { - Image( - painter = painterResource(id = R.drawable.full_customization_phone_mascot), - contentDescription = "doggo - cute phone sign-in mascot", - modifier = Modifier.size(72.dp), - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Enter your code", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.primary, + ThemeHeader( + title = "Enter your code", + mascot = R.drawable.full_customization_phone_mascot, + mascotDescription = "doggo - cute phone sign-in mascot", ) Spacer(modifier = Modifier.height(24.dp)) @@ -68,7 +68,7 @@ fun PhoneVerificationStep(state: PhoneAuthContentState) { modifier = Modifier .fillMaxWidth() .semantics { contentDescription = "phone - verification card" }, - color = MaterialTheme.colorScheme.surface, + color = LocalFullCustomizationStyle.current.cardFill, shape = AuthFieldShape, ) { Column( diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/reauth/ReauthEmailStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/reauth/ReauthEmailStep.kt index d22ee432be..d907e99734 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/reauth/ReauthEmailStep.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/reauth/ReauthEmailStep.kt @@ -1,6 +1,5 @@ package com.firebaseui.android.demo.auth.fullcustomization.screens.reauth -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -9,7 +8,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff @@ -19,11 +17,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign @@ -33,6 +30,7 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.common.ThemeHeader import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField @@ -62,18 +60,11 @@ fun ReauthEmailStep(state: EmailAuthContentState) { .padding(bottom = 24.dp) .semantics { contentDescription = "reauth - email password card" }, ) { - Image( - painter = painterResource(id = R.drawable.full_customization_mascot), - contentDescription = "doggo - cute welcome mascot", - modifier = Modifier.size(56.dp), - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = "Confirm it's you", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.primary, + ThemeHeader( + title = "Confirm it's you", + mascot = R.drawable.full_customization_mascot, + mascotDescription = "doggo - cute welcome mascot", + mascotSize = 56.dp, ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationShapes.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationShapes.kt deleted file mode 100644 index 483b0b741d..0000000000 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationShapes.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.firebaseui.android.demo.auth.fullcustomization.theme - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.ui.unit.dp - -val IntroShape = RoundedCornerShape(80.dp) -val ButtonShape = RoundedCornerShape(36.dp) -val ProviderButtonShape = RoundedCornerShape(percent = 50) diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationStyle.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationStyle.kt new file mode 100644 index 0000000000..03233f496e --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationStyle.kt @@ -0,0 +1,465 @@ +package com.firebaseui.android.demo.auth.fullcustomization.theme + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * The demo's looks. Doggo is the Android Figma section; Grid and Coffee Spark come from the iOS + * Landing frames (20:5467 and 20:5769) by way of the iOS demo's values, laid out on Doggo's + * Android sizing. Professional has no Figma frame: it follows the iOS demo's written spec. + * + * Figma has no dark frames. Each dark variant keeps a colour's HCT hue and chroma and moves only + * its tone: pages to tone 6 (never pure black), text to 90, Doggo's M3 roles to M3's dark tones. + * Grid's and Coffee Spark's neon accents keep their own tone, since each already clears 4.5:1 on + * the dark page. On their light pages the same rule runs the other way: accents used as text keep + * hue and chroma and darken until they clear 4.5:1, while button fills stay Figma's neon. + */ +enum class FullCustomizationThemeId(val label: String) { + Doggo("Doggo"), + Grid("Grid"), + Spark("Coffee Spark"), + Professional("Professional"), +} + +/** A hard offset shadow: Figma's zero-blur drop shadow. */ +@Immutable +data class HardShadow(val color: Color, val offset: DpOffset) + +@Immutable +data class ButtonPalette(val container: Color, val content: Color) + +/** What a theme changes beyond [colorScheme] and [typography], read via [LocalFullCustomizationStyle]. */ +@Immutable +data class FullCustomizationStyle( + val id: FullCustomizationThemeId, + val colorScheme: ColorScheme, + val typography: Typography, + val titleColor: Color, + /** Doggo's mascots; the other themes have none. */ + val showsMascot: Boolean, + /** The form card's fill and shadow. Doggo draws a card; Grid and Coffee Spark leave the form bare. */ + val cardFill: Color, + val cardShadow: HardShadow?, + val fieldShape: Shape, + val fieldBorderWidth: Dp, + /** Fields and the chips drawn like them, such as the country code. */ + val fieldContainer: Color, + val buttonShape: Shape, + val buttonBorder: BorderStroke?, + val buttonShadow: HardShadow?, + val primaryButton: ButtonPalette, + val secondaryButton: ButtonPalette, + val providerButtonShape: Shape, + val phoneProvider: ButtonPalette, + /** The floating pills: the theme switcher and Coffee Spark's headline. */ + val pill: ButtonPalette, + /** A wash of the page colour over the background art, so light art sits back in dark mode. */ + val artScrim: Color, + val buttonHeight: Dp = 80.dp, +) + +val LocalFullCustomizationStyle = staticCompositionLocalOf { + error("FullCustomizationStyle is provided by FullCustomizationTheme") +} + +fun FullCustomizationThemeId.style(darkTheme: Boolean): FullCustomizationStyle = when (this) { + FullCustomizationThemeId.Doggo -> if (darkTheme) DoggoDark else DoggoLight + FullCustomizationThemeId.Grid -> if (darkTheme) GridDark else GridLight + FullCustomizationThemeId.Spark -> if (darkTheme) SparkDark else SparkLight + FullCustomizationThemeId.Professional -> if (darkTheme) ProfessionalDark else ProfessionalLight +} + +/** A theme's page and its tone-mapped neighbours, light or dark. */ +private class Neutrals( + val page: Color, + val ink: Color, + /** Fields, secondary buttons and other surfaces one step above the page. */ + val raised: Color, + /** surfaceContainerLowest to surfaceContainerHighest, for sheets and dialogs. */ + val containers: List, + val error: Color, +) + +private fun ColorScheme.withNeutrals(n: Neutrals) = copy( + surface = n.page, + surfaceBright = n.page, + surfaceContainerLowest = n.containers[0], + surfaceContainerLow = n.containers[1], + surfaceContainer = n.containers[2], + surfaceContainerHigh = n.containers[3], + surfaceContainerHighest = n.containers[4], + background = n.page, + onSurface = n.ink, + onSurfaceVariant = n.ink, + outline = n.ink, + primaryContainer = n.raised, + onPrimaryContainer = n.ink, + error = n.error, +) + +private fun doggo( + scheme: ColorScheme, + cardFill: Color, + fieldContainer: Color, + buttonShadow: Color, + pill: ButtonPalette, + artScrim: Color, +) = FullCustomizationStyle( + id = FullCustomizationThemeId.Doggo, + colorScheme = scheme, + typography = fullCustomizationTypography( + title = BagelFatOne, + titleWeight = FontWeight.Normal, + body = Onest, + button = Onest, + ), + titleColor = scheme.primary, + showsMascot = true, + cardFill = cardFill, + cardShadow = HardShadow(scheme.primaryContainer, DpOffset(3.dp, 6.dp)), + fieldShape = RoundedCornerShape(24.dp), + fieldBorderWidth = 1.dp, + fieldContainer = fieldContainer, + buttonShape = RoundedCornerShape(36.dp), + buttonBorder = null, + buttonShadow = HardShadow(buttonShadow, DpOffset(2.dp, 4.dp)), + primaryButton = ButtonPalette(scheme.primary, scheme.onPrimary), + secondaryButton = ButtonPalette(scheme.primaryContainer, scheme.onPrimaryContainer), + providerButtonShape = RoundedCornerShape(percent = 50), + phoneProvider = ButtonPalette(scheme.primary, scheme.onPrimary), + pill = pill, + artScrim = artScrim, +) + +private val DoggoLight = doggo( + scheme = FullCustomizationLightColorScheme, + cardFill = FullCustomizationLightColorScheme.surface, + fieldContainer = Color.White, + buttonShadow = Color(0xFF5D0B47), + pill = ButtonPalette(Color.White, Color(0xFF211A1D)), + artScrim = Color.Transparent, +) + +private val DoggoDark = doggo( + scheme = FullCustomizationDarkColorScheme, + // Raised surfaces get lighter in dark mode: the card one step above the page, fields one above it. + cardFill = Color(0xFF231E20), + fieldContainer = Color(0xFF2E282B), + // Tone 30 of the light shadow's hue, so it still sits below the tone 80 button. + buttonShadow = Color(0xFF79265F), + pill = ButtonPalette(Color(0xFF2E282B), Color(0xFFEDDFE4)), + artScrim = Color(0xFF171214).copy(alpha = 0.92f), +) + +private val GridNavy = Color(0xFF2C1067) +private val GridPink = Color(0xFFFF52B3) +private val GridCyan = Color(0xFF00DCE2) + +private fun grid( + n: Neutrals, + scheme: ColorScheme, + /** Links and accent text; the pink button fill stays [GridPink]. */ + link: Color, + fieldContainer: Color, + pill: Color, + artScrim: Color, +) = + FullCustomizationStyle( + id = FullCustomizationThemeId.Grid, + colorScheme = scheme.withNeutrals(n).copy( + primary = link, + onPrimary = GridNavy, + secondary = GridCyan, + onSecondary = GridNavy, + outlineVariant = GridCyan, + ), + typography = fullCustomizationTypography( + title = Rowdies, + titleWeight = FontWeight.Normal, + body = ChivoMono, + button = Rowdies, + ), + titleColor = n.ink, + showsMascot = false, + cardFill = Color.Transparent, + cardShadow = null, + fieldShape = RoundedCornerShape(16.dp), + fieldBorderWidth = 2.dp, + fieldContainer = fieldContainer, + buttonShape = RoundedCornerShape(24.dp), + buttonBorder = BorderStroke(2.dp, GridCyan), + buttonShadow = HardShadow(GridCyan, DpOffset(3.dp, 6.dp)), + // Navy on the pink fill reads in both modes, so the primary button doesn't change. + primaryButton = ButtonPalette(GridPink, GridNavy), + secondaryButton = ButtonPalette(n.raised, n.ink), + providerButtonShape = RoundedCornerShape(12.dp), + phoneProvider = ButtonPalette(GridCyan, GridNavy), + pill = ButtonPalette(pill, n.ink), + artScrim = artScrim, + ) + +private val GridLight = grid( + n = Neutrals( + page = Color(0xFFF3FBFB), + ink = GridNavy, + raised = Color(0xFFF3FBFB), + containers = List(5) { Color(0xFFF3FBFB) }, + error = Color(0xFFBA1A1A), + ), + scheme = lightColorScheme(), + link = Color(0xFFD0288D), + fieldContainer = Color.White, + pill = Color.White, + artScrim = Color.Transparent, +) + +private val GridDark = grid( + n = Neutrals( + page = Color(0xFF141218), + ink = Color(0xFFE7DFF2), + raised = Color(0xFF211E26), + containers = listOf( + Color(0xFF0F0D15), + Color(0xFF1D1A22), + Color(0xFF211E26), + Color(0xFF2B2931), + Color(0xFF36333C), + ), + error = Color(0xFFFFB4AB), + ), + scheme = darkColorScheme(), + link = GridPink, + fieldContainer = Color(0xFF211E26), + pill = Color(0xFF2B2931), + // The cyan lines are loud on the dark page; a wash keeps them behind the text. + artScrim = Color(0xFF141218).copy(alpha = 0.4f), +) + +private val SparkGreen = Color(0xFF34A853) +// SparkGreen darkened to clear 4.5:1 on the light page and on the lime phone button. +private val SparkGreenInk = Color(0xFF007C33) + +private fun spark( + n: Neutrals, + scheme: ColorScheme, + /** Links, accent text and the focused field border. */ + link: Color, + title: Color, + fieldContainer: Color, + pill: Color, + artScrim: Color, +) = + FullCustomizationStyle( + id = FullCustomizationThemeId.Spark, + colorScheme = scheme.withNeutrals(n).copy( + primary = link, + onPrimary = Color.Black, + // The field border is ink unfocused, so focus takes the green. + secondary = link, + onSecondary = n.page, + outlineVariant = n.ink, + ), + typography = fullCustomizationTypography( + title = JetBrainsMono, + titleWeight = FontWeight.Medium, + body = JetBrainsMono, + button = JetBrainsMono, + ), + titleColor = title, + showsMascot = false, + cardFill = Color.Transparent, + cardShadow = null, + fieldShape = RoundedCornerShape(16.dp), + fieldBorderWidth = 2.dp, + fieldContainer = fieldContainer, + buttonShape = RoundedCornerShape(40.dp), + // The black outlines become the light ink in dark mode. + buttonBorder = BorderStroke(2.dp, n.ink), + buttonShadow = null, + primaryButton = ButtonPalette(Color(0xFFFDADEE), Color.Black), + secondaryButton = ButtonPalette(n.raised, n.ink), + providerButtonShape = RoundedCornerShape(40.dp), + phoneProvider = ButtonPalette(Color(0xFFC6FF00), SparkGreenInk), + pill = ButtonPalette(pill, title), + artScrim = artScrim, + ) + +private val SparkLight = spark( + n = Neutrals( + page = Color(0xFFF1F1F1), + ink = Color.Black, + raised = Color(0xFFF3FBFB), + containers = List(5) { Color(0xFFF1F1F1) }, + error = Color(0xFFBA1A1A), + ), + scheme = lightColorScheme(), + link = SparkGreenInk, + title = Color(0xFF1C1B1B), + fieldContainer = Color.White, + pill = Color.White, + artScrim = Color.Transparent, +) + +private val SparkDark = spark( + n = Neutrals( + page = Color(0xFF151313), + ink = Color(0xFFE8E1E1), + raised = Color(0xFF221F20), + containers = listOf( + Color(0xFF100E0E), + Color(0xFF1E1B1C), + Color(0xFF221F20), + Color(0xFF2C292A), + Color(0xFF373435), + ), + error = Color(0xFFFFB4AB), + ), + scheme = darkColorScheme(), + link = SparkGreen, + title = Color(0xFFE8E1E1), + fieldContainer = Color(0xFF221F20), + pill = Color(0xFF2C292A), + // The blob is bright enough to fight the text over it on a dark page. + artScrim = Color(0xFF151313).copy(alpha = 0.55f), +) + +/** + * Professional's colours, the iOS demo's `ProPalette` (firebase/FirebaseUI-iOS#1420): neutral greys + * with one blue accent, in light and dark. Its screens read these directly; [professional] maps + * them onto the colour scheme for the library components they reuse. + */ +@Immutable +data class ProColors( + val background: Color, + val surface: Color, + val field: Color, + val text: Color, + val muted: Color, + val placeholder: Color, + val border: Color, + val hairline: Color, + val accent: Color, + val accentFill: Color, + val accentSoft: Color, + val accentSoftText: Color, + val tile: Color, + val danger: Color, + val chevron: Color, +) + +val ProLightColors = ProColors( + background = Color(0xFFF5F6F8), + surface = Color(0xFFFFFFFF), + field = Color(0xFFFFFFFF), + text = Color(0xFF111418), + muted = Color(0xFF5B6470), + placeholder = Color(0xFF6B7480), + border = Color(0xFFC9CFD6), + hairline = Color(0xFFE3E6EA), + accent = Color(0xFF1F5EFF), + accentFill = Color(0xFF1F5EFF), + accentSoft = Color(0xFFEEF2FF), + accentSoftText = Color(0xFF1F5EFF), + tile = Color(0xFFEEF0F3), + danger = Color(0xFFB42318), + chevron = Color(0xFF9AA3AE), +) + +val ProDarkColors = ProColors( + background = Color(0xFF0F1115), + surface = Color(0xFF181B20), + field = Color(0xFF0F1115), + text = Color(0xFFF2F4F7), + muted = Color(0xFF9AA3AE), + placeholder = Color(0xFF7D8693), + border = Color(0xFF3A414A), + hairline = Color(0xFF2A2F36), + accent = Color(0xFF4C7DFF), + // Darker than accent in dark mode, so white labels on it keep 4.5:1. + accentFill = Color(0xFF2F62F0), + accentSoft = Color(0xFF1C2540), + accentSoftText = Color(0xFF9DB5FF), + tile = Color(0xFF22262D), + danger = Color(0xFFF97066), + chevron = Color(0xFF5B6470), +) + +/** + * A quiet, platform-native look with no Figma frame, matching the iOS demo's Professional theme: + * the system font, neutral greys with one blue accent, 10dp corners, hairline borders instead of + * offset shadows, no background art. Its screens are laid out differently too (the `professional` + * package). + */ +private fun professional(c: ProColors, darkTheme: Boolean) = FullCustomizationStyle( + id = FullCustomizationThemeId.Professional, + colorScheme = (if (darkTheme) darkColorScheme() else lightColorScheme()).copy( + primary = c.accent, + onPrimary = Color.White, + // The focused field border. + secondary = c.accent, + onSecondary = Color.White, + // The other-methods sheet. + primaryContainer = c.surface, + onPrimaryContainer = c.text, + surface = c.background, + surfaceBright = c.background, + surfaceContainerLowest = c.surface, + surfaceContainerLow = c.surface, + surfaceContainer = c.surface, + surfaceContainerHigh = c.surface, + surfaceContainerHighest = c.surface, + background = c.background, + onSurface = c.text, + onSurfaceVariant = c.muted, + outline = c.border, + outlineVariant = c.border, + error = c.danger, + ), + typography = fullCustomizationTypography( + title = FontFamily.Default, + titleWeight = FontWeight.SemiBold, + body = FontFamily.Default, + button = FontFamily.Default, + ).let { + it.copy( + headlineSmall = it.headlineSmall.copy(fontSize = 26.sp, lineHeight = 32.sp, letterSpacing = (-0.3).sp), + bodyLarge = it.bodyLarge.copy(fontWeight = FontWeight.Normal), + titleMedium = it.titleMedium.copy(fontSize = 17.sp, fontWeight = FontWeight.SemiBold), + ) + }, + titleColor = c.text, + showsMascot = false, + cardFill = c.surface, + cardShadow = null, + fieldShape = RoundedCornerShape(10.dp), + fieldBorderWidth = 1.dp, + fieldContainer = c.field, + buttonShape = RoundedCornerShape(10.dp), + buttonBorder = null, + buttonShadow = null, + primaryButton = ButtonPalette(c.accentFill, Color.White), + secondaryButton = ButtonPalette(c.accentSoft, c.accentSoftText), + providerButtonShape = RoundedCornerShape(10.dp), + phoneProvider = ButtonPalette(c.accentFill, Color.White), + pill = ButtonPalette(c.surface, c.text), + artScrim = Color.Transparent, + buttonHeight = 52.dp, +) + +private val ProfessionalLight = professional(ProLightColors, darkTheme = false) + +private val ProfessionalDark = professional(ProDarkColors, darkTheme = true) diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTheme.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTheme.kt index 53b9161191..5cf7b9e756 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTheme.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTheme.kt @@ -4,10 +4,9 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.graphics.Color import com.firebase.ui.auth.configuration.theme.AuthUITheme -import kotlin.math.max -import kotlin.math.min private val LightPrimary = Color(0xFF864B6F) private val LightOnPrimary = Color(0xFFFFFFFF) @@ -51,85 +50,51 @@ val FullCustomizationLightColorScheme = lightColorScheme( inverseOnSurface = LightInverseOnSurface, ) +/** + * Doggo's palette sits on Material 3 tones (primary 40, containers 90, surface 98), so its dark + * scheme takes M3's dark tone for each role: same HCT hue and chroma, tone moved (primary 40 to 80, + * containers 90 to 30, surface 98 to 6, text 10 to 90). + */ val FullCustomizationDarkColorScheme = darkColorScheme( - primary = LightPrimary.withLightness(0.78f), - onPrimary = LightOnPrimary.withLightness(0.18f), - primaryContainer = LightPrimaryContainer.withLightness(0.28f), - onPrimaryContainer = LightOnPrimaryContainer.withLightness(0.88f), + primary = Color(0xFFFAB1DA), + onPrimary = Color(0xFF511D3F), + primaryContainer = Color(0xFF6B3457), + onPrimaryContainer = Color(0xFFFFD8EB), inversePrimary = LightPrimary, - secondary = LightSecondary.withLightness(0.78f), - onSecondary = LightOnSecondary.withLightness(0.18f), - secondaryContainer = LightSecondaryContainer.withLightness(0.28f), - tertiaryContainer = LightTertiaryContainer.withLightness(0.28f), - surface = LightSurface.withLightness(0.10f), - surfaceBright = LightSurfaceBright.withLightness(0.20f), - onSurface = LightOnSurface.withLightness(0.88f), - onSurfaceVariant = LightOnSurfaceVariant.withLightness(0.75f), - surfaceContainer = LightSurfaceContainer.withLightness(0.13f), - surfaceContainerLow = LightSurfaceContainerLow.withLightness(0.11f), - outline = LightOutline.withLightness(0.55f), - outlineVariant = LightOutlineVariant.withLightness(0.30f), - inverseSurface = LightSurface.withLightness(0.90f), - inverseOnSurface = LightOnSurface.withLightness(0.15f), + secondary = Color(0xFFAFC6FF), + onSecondary = Color(0xFF002D6C), + surface = Color(0xFF171214), + surfaceBright = Color(0xFF3D383A), + surfaceContainerLowest = Color(0xFF110D0F), + surfaceContainerLow = Color(0xFF1F1A1C), + surfaceContainer = Color(0xFF231E20), + surfaceContainerHigh = Color(0xFF2E282B), + surfaceContainerHighest = Color(0xFF393336), + background = Color(0xFF171214), + onSurface = Color(0xFFEDDFE4), + onSurfaceVariant = Color(0xFFD8C0CB), + outline = Color(0xFF9C8D94), + outlineVariant = Color(0xFF4F4349), + inverseSurface = Color(0xFFEDDFE4), + inverseOnSurface = Color(0xFF322F35), + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005), ) @Composable -fun FullCustomizationTheme(content: @Composable () -> Unit) { - val colorScheme = if (isSystemInDarkTheme()) { - FullCustomizationDarkColorScheme - } else { - FullCustomizationLightColorScheme +fun FullCustomizationTheme( + themeId: FullCustomizationThemeId, + content: @Composable () -> Unit, +) { + val style = themeId.style(darkTheme = isSystemInDarkTheme()) + CompositionLocalProvider(LocalFullCustomizationStyle provides style) { + AuthUITheme( + theme = AuthUITheme.Default.copy( + colorScheme = style.colorScheme, + typography = style.typography, + providerButtonShape = style.providerButtonShape, + ), + content = content, + ) } - AuthUITheme( - theme = AuthUITheme.Default.copy( - colorScheme = colorScheme, - typography = FullCustomizationTypography, - providerButtonShape = ProviderButtonShape, - ), - content = content, - ) -} - -private fun Color.withLightness(newLightness: Float): Color { - val (h, s, _) = toHsl() - return hslToColor(h, s, newLightness.coerceIn(0f, 1f), alpha) -} - -private fun Color.toHsl(): Triple { - val r = red - val g = green - val b = blue - val maxC = max(r, max(g, b)) - val minC = min(r, min(g, b)) - val l = (maxC + minC) / 2f - if (maxC == minC) return Triple(0f, 0f, l) - val d = maxC - minC - val s = if (l > 0.5f) d / (2f - maxC - minC) else d / (maxC + minC) - val h = when (maxC) { - r -> ((g - b) / d + (if (g < b) 6f else 0f)) - g -> ((b - r) / d + 2f) - else -> ((r - g) / d + 4f) - } / 6f - return Triple(h, s, l) -} - -private fun hslToColor(h: Float, s: Float, l: Float, alpha: Float): Color { - if (s == 0f) return Color(l, l, l, alpha) - fun hueToRgb(p: Float, q: Float, tIn: Float): Float { - var t = tIn - if (t < 0f) t += 1f - if (t > 1f) t -= 1f - return when { - t < 1f / 6f -> p + (q - p) * 6f * t - t < 1f / 2f -> q - t < 2f / 3f -> p + (q - p) * (2f / 3f - t) * 6f - else -> p - } - } - val q = if (l < 0.5f) l * (1f + s) else l + s - l * s - val p = 2f * l - q - val r = hueToRgb(p, q, h + 1f / 3f) - val g = hueToRgb(p, q, h) - val b = hueToRgb(p, q, h - 1f / 3f) - return Color(r, g, b, alpha) } diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTypography.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTypography.kt index da31b8bc23..f23f781285 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTypography.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTypography.kt @@ -24,21 +24,45 @@ val Roboto = FontFamily( Font(R.font.roboto_bold, FontWeight.Bold), ) -val FullCustomizationTypography = Typography( +val Rowdies = FontFamily( + Font(R.font.rowdies_regular, FontWeight.Normal), + Font(R.font.rowdies_bold, FontWeight.Bold), +) + +val ChivoMono = FontFamily( + Font(R.font.chivo_mono_medium, FontWeight.Medium), + Font(R.font.chivo_mono_bold, FontWeight.Bold), +) + +val JetBrainsMono = FontFamily( + Font(R.font.jetbrains_mono_medium, FontWeight.Medium), + Font(R.font.jetbrains_mono_bold, FontWeight.Bold), +) + +/** + * Doggo's type scale with a theme's families swapped in: [title] for headlines, [body] for text + * and fields, [button] for CTA labels. Sizes stay Doggo's on every theme. + */ +fun fullCustomizationTypography( + title: FontFamily, + titleWeight: FontWeight, + body: FontFamily, + button: FontFamily, +) = Typography( headlineSmall = TextStyle( - fontFamily = BagelFatOne, - fontWeight = FontWeight.Normal, + fontFamily = title, + fontWeight = titleWeight, fontSize = 28.sp, lineHeight = 36.sp, ), headlineMedium = TextStyle( - fontFamily = BagelFatOne, - fontWeight = FontWeight.Normal, + fontFamily = title, + fontWeight = titleWeight, fontSize = 36.sp, lineHeight = 44.sp, ), bodyLarge = TextStyle( - fontFamily = Onest, + fontFamily = body, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 24.sp, @@ -51,7 +75,7 @@ val FullCustomizationTypography = Typography( letterSpacing = 0.1.sp, ), titleMedium = TextStyle( - fontFamily = Onest, + fontFamily = button, fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 20.sp, diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/GridPattern.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/GridPattern.kt new file mode 100644 index 0000000000..f7dcd59379 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/GridPattern.kt @@ -0,0 +1,133 @@ +package com.firebaseui.android.demo.auth.fullcustomization.theme + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.unit.dp + +// The SVG's group opacity, applied per path: ImageVector groups have no alpha. +private const val OPACITY = 0.3f + +private fun ImageVector.Builder.line(d: String) = addPath( + pathData = addPathNodes(d), + stroke = SolidColor(Color(0xFF00DCE2)), + strokeAlpha = OPACITY, + strokeLineWidth = 1.42395f, +) + +private fun ImageVector.Builder.shape(d: String, color: Color) = addPath( + pathData = addPathNodes(d), + fill = SolidColor(color), + fillAlpha = OPACITY, +) + +/** + * Grid's background: the iOS demo's grid-pattern.svg (Figma 20:5467 "Frame 5"), its gradient + * fading to [page] so the same art works on the dark page. An [ImageVector] rather than a vector + * drawable, which can't draw the gradient below API 24. + */ +fun gridPattern(page: Color): ImageVector = + ImageVector.Builder( + name = "GridPattern", + defaultWidth = 997.dp, + defaultHeight = 997.dp, + viewportWidth = 997f, + viewportHeight = 997f, + ).apply { + line("M209.246 6.92089L32.011 826.686") + line("M987.671 220.381L167.906 43.1455") + line("M587.813 88.7679L410.578 908.533") + line("M905.824 598.948L86.059 421.712") + line("M387.395 45.4372L210.16 865.202") + line("M949.155 398.53L129.39 221.295") + line("M765.962 127.284L588.727 947.049") + line("M867.308 777.097L47.5433 599.862") + line("M276.052 21.3645L98.8167 841.129") + line("M973.228 287.186L153.463 109.951") + line("M654.619 103.212L477.384 922.976") + line("M891.381 665.754L71.6158 488.519") + line("M454.202 59.8808L276.966 879.645") + line("M934.712 465.336L114.947 288.101") + line("M832.768 141.728L655.533 961.492") + line("M852.865 843.903L33.1002 666.667") + line("M342.857 35.8082L165.622 855.573") + line("M958.784 353.992L139.02 176.757") + line("M721.425 117.655L544.19 937.42") + line("M876.937 732.559L57.1727 555.324") + line("M521.007 74.3244L343.772 894.089") + line("M920.268 532.142L100.503 354.907") + line("M899.574 156.172L722.339 975.936") + line("M838.421 910.709L18.6561 733.474") + line("M231.514 11.7352L54.2792 831.5") + line("M982.857 242.649L163.092 65.4141") + line("M610.082 93.5827L432.847 913.347") + line("M901.01 621.216L81.2452 443.981") + line("M409.664 50.2519L232.429 870.017") + line("M944.341 420.798L124.577 243.563") + line("M788.231 132.099L610.996 951.863") + line("M862.493 799.366L42.7286 622.131") + line("M298.321 26.1788L121.086 845.943") + line("M968.413 309.455L148.648 132.22") + line("M676.888 108.026L499.653 927.791") + line("M886.566 688.022L66.8011 510.787") + line("M476.47 64.6952L299.235 884.46") + line("M929.897 487.604L110.132 310.369") + line("M855.037 146.542L677.802 966.307") + line("M848.05 866.171L28.2855 688.936") + line("M365.127 40.6226L187.892 860.387") + line("M953.97 376.261L134.205 199.026") + line("M743.694 122.47L566.458 942.234") + line("M872.123 754.828L52.358 577.593") + line("M543.275 79.1389L366.04 898.904") + line("M915.454 554.41L95.6893 377.175") + line("M921.842 160.986L744.607 980.751") + line("M833.607 932.977L13.8423 755.742") + line("M253.783 16.55L76.5484 836.315") + line("M978.042 264.918L158.278 87.6827") + line("M632.35 98.397L455.115 918.162") + line("M896.195 643.485L76.4305 466.25") + line("M431.932 55.0662L254.697 874.831") + line("M939.526 443.067L119.761 265.832") + line("M810.499 136.913L633.264 956.678") + line("M857.679 821.634L37.9139 644.399") + line("M320.589 30.9936L143.354 850.758") + line("M963.599 331.724L143.834 154.489") + line("M699.156 112.841L521.921 932.605") + line("M881.752 710.291L61.9874 533.056") + line("M498.738 69.5097L321.503 889.274") + line("M925.082 509.873L105.318 332.638") + line("M877.306 151.357L700.071 971.121") + line("M843.235 888.44L23.4708 711.205") + line("M387.395 45.4372L210.16 865.202") + line("M949.155 398.53L129.39 221.295") + line("M765.962 127.284L588.727 947.049") + line("M867.308 777.097L47.5433 599.862") + line("M565.545 83.9533L388.31 903.718") + line("M910.639 576.679L90.8746 399.444") + line("M944.112 165.8L766.877 985.565") + line("M828.791 955.246L9.02666 778.011") + group(rotate = 12.1997f, pivotX = 177.235f, pivotY = 0f) { + addPath( + pathData = addPathNodes("M177.235,0.0h838.705v838.705h-838.705z"), + fill = Brush.linearGradient( + 0.134118f to page, 0.5f to page.copy(alpha = 0f), 0.847192f to page, + start = Offset(597.3f, 0f), + end = Offset(597.153f, 838.705f), + ), + // Full alpha: under group opacity the SVG's mask hides the lines where it is + // opaque, and a page-coloured fill on the page reproduces that. + fillAlpha = 1f, + ) + } + shape("M563.338 135.969C562.988 130.004 569.059 125.771 574.535 128.162L636.67 155.29C642.406 157.795 643.179 165.616 638.045 169.196L579.885 209.749C574.751 213.329 567.679 209.899 567.312 203.651L563.338 135.969Z", Color(0xFFFF52B3)) + shape("M453.143 293.236C454.88 287.518 462.041 285.648 466.351 289.787L515.254 336.746C519.768 341.081 517.787 348.687 511.731 350.268L443.129 368.183C437.073 369.764 431.626 364.098 433.444 358.109L453.143 293.236Z", Color(0xFFFF52B3)) + shape("M455.3927,218.188a42.0363,42.0363 0,1 0,84.0726,0a42.0363,42.0363 0,1 0,-84.0726,0z", Color(0xFF00DCE2)) + shape("M548.4735000000001,311.769a51.5445,51.5445 0,1 0,103.089,0a51.5445,51.5445 0,1 0,-103.089,0z", Color(0xFFC6FF00)) + shape("M382.562 207.813C382.866 200.062 392.91 197.229 397.22 203.678L399.689 207.373C401.616 210.257 405.182 211.573 408.52 210.631L412.797 209.424C420.262 207.319 426.061 215.996 421.259 222.087L418.508 225.577C416.361 228.302 416.212 232.099 418.139 234.983L420.608 238.678C424.918 245.127 418.457 253.323 411.18 250.639L407.01 249.101C403.756 247.9 400.098 248.932 397.951 251.656L395.2 255.146C390.398 261.238 380.607 257.626 380.911 249.875L381.085 245.435C381.221 241.969 379.11 238.809 375.856 237.609L371.686 236.071C364.409 233.386 364.818 222.958 372.283 220.852L376.561 219.646C379.899 218.704 382.252 215.72 382.388 212.254L382.562 207.813Z", Color(0xFFC6FF00)) + shape("M671.453 195.059C677.539 190.251 686.222 196.041 684.124 203.508L682.922 207.786C681.984 211.126 683.302 214.69 686.189 216.614L689.886 219.079C696.339 223.382 693.516 233.43 685.766 233.742L681.326 233.92C677.86 234.06 674.878 236.415 673.939 239.755L672.737 244.033C670.639 251.501 660.211 251.92 657.52 244.646L655.977 240.478C654.774 237.225 651.612 235.117 648.146 235.256L643.705 235.435C635.955 235.747 632.334 225.959 638.42 221.151L641.908 218.397C644.63 216.247 645.658 212.588 644.454 209.335L642.912 205.167C640.22 197.893 648.409 191.424 654.863 195.727L658.56 198.192C661.446 200.116 665.243 199.964 667.965 197.813L671.453 195.059Z", Color(0xFF00DCE2)) + }.build() + diff --git a/app/src/main/res/drawable-nodpi/full_customization_spark_blob.webp b/app/src/main/res/drawable-nodpi/full_customization_spark_blob.webp new file mode 100644 index 0000000000..d2c6ce995d Binary files /dev/null and b/app/src/main/res/drawable-nodpi/full_customization_spark_blob.webp differ diff --git a/app/src/main/res/font/chivo_mono_bold.ttf b/app/src/main/res/font/chivo_mono_bold.ttf new file mode 100644 index 0000000000..93a56f6de4 Binary files /dev/null and b/app/src/main/res/font/chivo_mono_bold.ttf differ diff --git a/app/src/main/res/font/chivo_mono_medium.ttf b/app/src/main/res/font/chivo_mono_medium.ttf new file mode 100644 index 0000000000..232a06976f Binary files /dev/null and b/app/src/main/res/font/chivo_mono_medium.ttf differ diff --git a/app/src/main/res/font/jetbrains_mono_bold.ttf b/app/src/main/res/font/jetbrains_mono_bold.ttf new file mode 100644 index 0000000000..1305efce9d Binary files /dev/null and b/app/src/main/res/font/jetbrains_mono_bold.ttf differ diff --git a/app/src/main/res/font/jetbrains_mono_medium.ttf b/app/src/main/res/font/jetbrains_mono_medium.ttf new file mode 100644 index 0000000000..51ccd916b8 Binary files /dev/null and b/app/src/main/res/font/jetbrains_mono_medium.ttf differ diff --git a/app/src/main/res/font/rowdies_bold.ttf b/app/src/main/res/font/rowdies_bold.ttf new file mode 100644 index 0000000000..ba852339da Binary files /dev/null and b/app/src/main/res/font/rowdies_bold.ttf differ diff --git a/app/src/main/res/font/rowdies_regular.ttf b/app/src/main/res/font/rowdies_regular.ttf new file mode 100644 index 0000000000..f65911f72a Binary files /dev/null and b/app/src/main/res/font/rowdies_regular.ttf differ