diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index bb74b0a75..abcf1afd2 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -68,6 +68,7 @@ dependencies {
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
+ implementation(libs.compose.material.icons.extended)
// Facebook
implementation(libs.facebook.login)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4bc5abcfe..6006db68b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -88,6 +88,12 @@
android:exported="false"
android:theme="@style/Theme.FirebaseUIAndroid" />
+
+
+ // customMethodPickerLayout now renders as the entire screen (no
+ // built-in logo/ToS footer/inset handling), so the terms checkbox
+ // that used to live in customMethodPickerTermsConfiguration is
+ // rendered inline here instead, and this composable owns its own
+ // insets via Modifier.safeDrawingPadding() in SpotlightMethodPicker.
SpotlightMethodPicker(
providers = providers,
onProviderSelected = onProviderSelected,
- enabled = termsAccepted
+ enabled = termsAccepted,
+ termsAccepted = termsAccepted,
+ onTermsAcceptedChange = { termsAccepted = it }
)
},
- customMethodPickerTermsConfiguration = MethodPickerTermsConfiguration(
- content = {
- Row(
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Checkbox(
- checked = termsAccepted,
- onCheckedChange = { termsAccepted = it }
- )
- Text(
- text = "I have read and accept the Terms of Service and Privacy Policy",
- style = MaterialTheme.typography.bodySmall,
- modifier = Modifier.padding(start = 8.dp)
- )
- }
- },
- accepted = termsAccepted,
- disableProvidersUntilAccepted = true,
- ),
)
}
}
@@ -180,6 +167,8 @@ fun SpotlightMethodPicker(
providers: List,
onProviderSelected: (AuthProvider) -> Unit,
enabled: Boolean = true,
+ termsAccepted: Boolean = true,
+ onTermsAcceptedChange: (Boolean) -> Unit = {},
) {
val stringProvider = LocalAuthUIStringProvider.current
@@ -197,7 +186,11 @@ fun SpotlightMethodPicker(
val anonymous = groups["anonymous"]?.firstOrNull()
LazyColumn(
- modifier = Modifier.fillMaxSize(),
+ // customMethodPickerLayout now renders as the entire screen, so this composable is
+ // responsible for its own insets.
+ modifier = Modifier
+ .fillMaxSize()
+ .safeDrawingPadding(),
contentPadding = PaddingValues(vertical = 48.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -290,11 +283,29 @@ fun SpotlightMethodPicker(
}
}
}
+
+ item {
+ Spacer(modifier = Modifier.height(16.dp))
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ checked = termsAccepted,
+ onCheckedChange = onTermsAcceptedChange
+ )
+ Text(
+ text = "I have read and accept the Terms of Service and Privacy Policy",
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(start = 8.dp)
+ )
+ }
+ }
}
}
@Composable
-private fun ProviderIconButton(
+fun ProviderIconButton(
style: AuthUITheme.ProviderStyle,
contentDescription: String,
onClick: () -> Unit,
@@ -331,12 +342,12 @@ private fun ProviderIconButton(
}
@Composable
-private fun AuthUIAsset.asPainter(): Painter = when (this) {
+fun AuthUIAsset.asPainter(): Painter = when (this) {
is AuthUIAsset.Resource -> painterResource(resId)
is AuthUIAsset.Vector -> rememberVectorPainter(image)
}
-private fun styleForProvider(provider: AuthProvider): AuthUITheme.ProviderStyle = when (provider) {
+fun styleForProvider(provider: AuthProvider): AuthUITheme.ProviderStyle = when (provider) {
is AuthProvider.Facebook -> ProviderStyleDefaults.Facebook
is AuthProvider.Twitter -> ProviderStyleDefaults.Twitter
is AuthProvider.Github -> ProviderStyleDefaults.Github
@@ -347,6 +358,7 @@ private fun styleForProvider(provider: AuthProvider): AuthUITheme.ProviderStyle
backgroundColor = provider.buttonColor ?: Color(0xFF666666),
contentColor = provider.contentColor ?: Color.White
)
+
else -> AuthUITheme.ProviderStyle(
icon = null,
backgroundColor = Color(0xFF666666),
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt
index df069091f..d270beac5 100644
--- a/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt
@@ -22,6 +22,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import com.firebaseui.android.demo.auth.fullcustomization.FullCustomizationDemoActivity
class CustomSlotsThemingDemoActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -46,6 +47,9 @@ class CustomSlotsThemingDemoActivity : ComponentActivity() {
},
onCustomMethodPickerClick = {
startActivity(Intent(this, CustomMethodPickerDemoActivity::class.java))
+ },
+ onFullCustomizationClick = {
+ startActivity(Intent(this, FullCustomizationDemoActivity::class.java))
}
)
}
@@ -60,6 +64,7 @@ fun CustomSlotsDemoChooser(
onPhoneAuthSlotClick: () -> Unit,
onShapeCustomizationClick: () -> Unit,
onCustomMethodPickerClick: () -> Unit,
+ onFullCustomizationClick: () -> Unit,
) {
Column(
modifier = Modifier
@@ -106,6 +111,12 @@ fun CustomSlotsDemoChooser(
description = "Replace the default provider list with a custom layout, and swap the 'By continuing...' footer with a checkbox using customMethodPickerLayout and customMethodPickerTermsConfiguration on FirebaseAuthScreen.",
onClick = onCustomMethodPickerClick
)
+
+ DemoCard(
+ title = "Full Customization",
+ description = "customMethodPickerLayout renders as the entire screen, so this layers a full-bleed background image and scrim behind the custom method picker.",
+ onClick = onFullCustomizationClick
+ )
}
}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/AuthTextFieldStyle.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/AuthTextFieldStyle.kt
new file mode 100644
index 000000000..998f29472
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/AuthTextFieldStyle.kt
@@ -0,0 +1,70 @@
+package com.firebaseui.android.demo.auth.fullcustomization
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+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.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import com.firebaseui.android.demo.R
+
+val AuthFieldShape = RoundedCornerShape(24.dp)
+
+@Composable
+fun authTextFieldColors(): TextFieldColors = OutlinedTextFieldDefaults.colors(
+ unfocusedContainerColor = Color.White,
+ focusedContainerColor = Color.White,
+ disabledContainerColor = Color.White,
+ unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant,
+ focusedBorderColor = MaterialTheme.colorScheme.secondary,
+)
+
+@Composable
+fun FullCustomizationTextField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ label: String? = null,
+ leadingIcon: @Composable (() -> Unit)? = null,
+ trailingIcon: @Composable (() -> Unit)? = null,
+ enabled: Boolean = true,
+ isError: Boolean = false,
+ supportingText: String? = null,
+ singleLine: Boolean = true,
+ visualTransformation: VisualTransformation = VisualTransformation.None,
+ shape: Shape = AuthFieldShape,
+) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ modifier = modifier,
+ label = label?.let { { Text(it) } },
+ leadingIcon = leadingIcon,
+ trailingIcon = trailingIcon,
+ enabled = enabled,
+ isError = isError,
+ supportingText = supportingText?.let { { Text(it) } },
+ singleLine = singleLine,
+ visualTransformation = visualTransformation,
+ shape = shape,
+ colors = authTextFieldColors(),
+ )
+}
+
+@Composable
+fun EmailFieldIcon() {
+ Image(
+ painter = painterResource(R.drawable.email_at_sign),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ )
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/CtaButton.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/CtaButton.kt
new file mode 100644
index 000000000..d3cd3f5b7
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/CtaButton.kt
@@ -0,0 +1,53 @@
+package com.firebaseui.android.demo.auth.fullcustomization
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+
+private val CtaShadowColor = Color(0xFF5D0B47)
+
+@Composable
+fun CtaButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ isLoading: Boolean = false,
+ colors: ButtonColors = ButtonDefaults.buttonColors(),
+) {
+ HardOffsetShadow(
+ shape = ButtonShape,
+ offsetX = 2.dp,
+ offsetY = 4.dp,
+ color = if (enabled) CtaShadowColor else Color.Transparent,
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ Button(
+ onClick = onClick,
+ enabled = enabled,
+ shape = ButtonShape,
+ colors = colors,
+ contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(80.dp),
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(modifier = Modifier.size(20.dp))
+ } else {
+ Text(text = text, style = MaterialTheme.typography.titleMedium)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/EmailEntryStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/EmailEntryStep.kt
new file mode 100644
index 000000000..32594a588
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/EmailEntryStep.kt
@@ -0,0 +1,178 @@
+package com.firebaseui.android.demo.auth.fullcustomization
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+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.offset
+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.rememberScrollState
+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.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+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.Brush
+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.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.zIndex
+import com.firebaseui.android.demo.R
+
+@Composable
+fun EmailEntryStep(
+ email: String,
+ onEmailChange: (String) -> Unit,
+ isLoading: Boolean,
+ onContinue: () -> Unit,
+ onShowOtherMethods: () -> Unit,
+) {
+ val isEmailValid = remember(email) {
+ android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()
+ }
+ val showEmailError = email.isNotBlank() && !isEmailValid
+
+ // verticalScroll measures content with infinite max height, and Column distributes weights
+ // against the MIN height when max is infinite (RowColumnMeasurePolicy.kt) — so
+ // heightIn(min = viewport) makes the weighted spacers expand (centering content, anchoring
+ // the link to the bottom) when everything fits, and collapse to zero (plain scrolling) when
+ // it doesn't.
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxSize()
+ .safeDrawingPadding(),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .heightIn(min = maxHeight)
+ .padding(horizontal = 48.dp, vertical = 24.dp),
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+
+ Column(
+ 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),
+ )
+ }
+
+ HardOffsetShadow(
+ shape = AuthFieldShape,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "email - sign in card" },
+ color = MaterialTheme.colorScheme.surface,
+ shape = AuthFieldShape,
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ text = "Enter your email address to continue.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ FullCustomizationTextField(
+ value = email,
+ onValueChange = onEmailChange,
+ label = "Email address",
+ leadingIcon = { EmailFieldIcon() },
+ enabled = !isLoading,
+ isError = showEmailError,
+ supportingText = if (showEmailError) "Enter a valid email address" else null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - email address input" },
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ CtaButton(
+ text = "Continue",
+ onClick = onContinue,
+ enabled = isEmailValid && !isLoading,
+ isLoading = isLoading,
+ modifier = Modifier.semantics { contentDescription = "button - continue to password" },
+ )
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ TextButton(
+ onClick = onShowOtherMethods,
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .semantics { contentDescription = "Other sign-in methods button" },
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Login,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Use other sign-in methods")
+ }
+ }
+ }
+}
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
new file mode 100644
index 000000000..9b78887c5
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationDemoActivity.kt
@@ -0,0 +1,143 @@
+package com.firebaseui.android.demo.auth.fullcustomization
+
+import android.os.Bundle
+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.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import com.firebase.ui.auth.AuthException
+import com.firebase.ui.auth.FirebaseAuthUI
+import com.firebase.ui.auth.configuration.AuthUIConfiguration
+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.ui.screens.email.EmailAuthScreen
+import com.firebaseui.android.demo.R
+
+class FullCustomizationDemoActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ val authUI = FirebaseAuthUI.getInstance()
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ logo = AuthUIAsset.Resource(R.drawable.firebase_auth)
+ tosUrl = "https://policies.google.com/terms"
+ privacyPolicyUrl = "https://policies.google.com/privacy"
+ providers {
+ provider(
+ AuthProvider.Google(
+ scopes = listOf("email"),
+ serverClientId = "406099696497-a12gakvts4epfk5pkio7dphc1anjiggc.apps.googleusercontent.com",
+ )
+ )
+ provider(AuthProvider.Apple(customParameters = emptyMap(), locale = null))
+ provider(AuthProvider.Facebook())
+ provider(AuthProvider.Twitter(customParameters = emptyMap()))
+ provider(AuthProvider.Github(customParameters = emptyMap()))
+ provider(AuthProvider.Microsoft(tenant = null, customParameters = emptyMap()))
+ provider(AuthProvider.Yahoo(customParameters = emptyMap()))
+ provider(
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
+ )
+ provider(
+ AuthProvider.Phone(
+ defaultNumber = null,
+ defaultCountryCode = null,
+ allowedCountries = null
+ )
+ )
+ provider(AuthProvider.Anonymous)
+ }
+ }
+
+ setContent {
+ FullCustomizationTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ FirebaseAuthScreen(
+ configuration = configuration,
+ authUI = authUI,
+ 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")
+ },
+ customMethodPickerLayout = { providers, onProviderSelected ->
+ FullCustomizationMethodPicker(
+ authUI = authUI,
+ configuration = configuration,
+ providers = providers,
+ onProviderSelected = onProviderSelected,
+ )
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun FullCustomizationMethodPicker(
+ authUI: FirebaseAuthUI,
+ configuration: AuthUIConfiguration,
+ providers: List,
+ onProviderSelected: (AuthProvider) -> Unit,
+) {
+ val context = LocalContext.current
+ Box(modifier = Modifier.fillMaxSize()) {
+ Image(
+ painter = painterResource(id = R.drawable.custom_background),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize()
+ )
+ Column(modifier = Modifier.fillMaxSize()) {
+ EmailAuthScreen(
+ context = context,
+ configuration = configuration,
+ authUI = authUI,
+ onSuccess = { result ->
+ Log.d("FullCustomizationDemo", "Auth success: ${result.user?.uid}")
+ },
+ onError = { exception ->
+ Log.e("FullCustomizationDemo", "Auth failed", exception)
+ },
+ onCancel = {
+ Log.d("FullCustomizationDemo", "Auth cancelled")
+ },
+ ) { state ->
+ SingleFieldSignInUI(
+ state = state,
+ otherProviders = providers.filterNot { it is AuthProvider.Email },
+ onProviderSelected = onProviderSelected,
+ tosUrl = configuration.tosUrl,
+ ppUrl = configuration.privacyPolicyUrl,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationShapes.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationShapes.kt
new file mode 100644
index 000000000..1c96ea2f2
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationShapes.kt
@@ -0,0 +1,8 @@
+package com.firebaseui.android.demo.auth.fullcustomization
+
+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/FullCustomizationTheme.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationTheme.kt
new file mode 100644
index 000000000..16ade7f50
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationTheme.kt
@@ -0,0 +1,135 @@
+package com.firebaseui.android.demo.auth.fullcustomization
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+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)
+private val LightPrimaryContainer = Color(0xFFFFD8EB)
+private val LightOnPrimaryContainer = Color(0xFF7B3B73)
+private val LightInversePrimary = Color(0xFFFAB1DA)
+private val LightSecondary = Color(0xFF4C8BFF)
+private val LightOnSecondary = Color(0xFFFFFFFF)
+private val LightSecondaryContainer = Color(0xFFCCE5FF)
+private val LightTertiaryContainer = Color(0xFFFFDDB4)
+private val LightSurface = Color(0xFFFFF8F8)
+private val LightSurfaceBright = Color(0xFFFFF8F8)
+private val LightOnSurface = Color(0xFF211A1D)
+private val LightOnSurfaceVariant = Color(0xFFA08B95)
+private val LightSurfaceContainer = Color(0xFFF9EAEF)
+private val LightSurfaceContainerLow = Color(0xFFFDF0F6)
+private val LightOutline = Color(0xFF81737A)
+private val LightOutlineVariant = Color(0xFFD3C2C9)
+private val LightInverseSurface = Color(0xFF322F35)
+private val LightInverseOnSurface = Color(0xFFF5EFF7)
+
+val FullCustomizationLightColorScheme = lightColorScheme(
+ primary = LightPrimary,
+ onPrimary = LightOnPrimary,
+ primaryContainer = LightPrimaryContainer,
+ onPrimaryContainer = LightOnPrimaryContainer,
+ inversePrimary = LightInversePrimary,
+ secondary = LightSecondary,
+ onSecondary = LightOnSecondary,
+ secondaryContainer = LightSecondaryContainer,
+ tertiaryContainer = LightTertiaryContainer,
+ surface = LightSurface,
+ surfaceBright = LightSurfaceBright,
+ onSurface = LightOnSurface,
+ onSurfaceVariant = LightOnSurfaceVariant,
+ surfaceContainer = LightSurfaceContainer,
+ surfaceContainerLow = LightSurfaceContainerLow,
+ outline = LightOutline,
+ outlineVariant = LightOutlineVariant,
+ inverseSurface = LightInverseSurface,
+ inverseOnSurface = LightInverseOnSurface,
+)
+
+val FullCustomizationDarkColorScheme = darkColorScheme(
+ primary = LightPrimary.withLightness(0.78f),
+ onPrimary = LightOnPrimary.withLightness(0.18f),
+ primaryContainer = LightPrimaryContainer.withLightness(0.28f),
+ onPrimaryContainer = LightOnPrimaryContainer.withLightness(0.88f),
+ 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),
+)
+
+@Composable
+fun FullCustomizationTheme(content: @Composable () -> Unit) {
+ val colorScheme = if (isSystemInDarkTheme()) {
+ FullCustomizationDarkColorScheme
+ } else {
+ FullCustomizationLightColorScheme
+ }
+ 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/FullCustomizationTypography.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationTypography.kt
new file mode 100644
index 000000000..501916e3a
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationTypography.kt
@@ -0,0 +1,59 @@
+package com.firebaseui.android.demo.auth.fullcustomization
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+import com.firebaseui.android.demo.R
+
+val BagelFatOne = FontFamily(Font(R.font.bagel_fat_one_regular, FontWeight.Normal))
+
+val Onest = FontFamily(
+ Font(R.font.onest_regular, FontWeight.Normal),
+ Font(R.font.onest_medium, FontWeight.Medium),
+ Font(R.font.onest_semibold, FontWeight.SemiBold),
+ Font(R.font.onest_bold, FontWeight.Bold),
+)
+
+val Roboto = FontFamily(
+ Font(R.font.roboto_regular, FontWeight.Normal),
+ Font(R.font.roboto_medium, FontWeight.Medium),
+ Font(R.font.roboto_semibold, FontWeight.SemiBold),
+ Font(R.font.roboto_bold, FontWeight.Bold),
+)
+
+val FullCustomizationTypography = Typography(
+ headlineSmall = TextStyle(
+ fontFamily = BagelFatOne,
+ fontWeight = FontWeight.Normal,
+ fontSize = 28.sp,
+ lineHeight = 36.sp,
+ ),
+ headlineMedium = TextStyle(
+ fontFamily = BagelFatOne,
+ fontWeight = FontWeight.Normal,
+ fontSize = 36.sp,
+ lineHeight = 44.sp,
+ ),
+ bodyLarge = TextStyle(
+ fontFamily = Onest,
+ fontWeight = FontWeight.Medium,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ ),
+ labelLarge = TextStyle(
+ fontFamily = Roboto,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp,
+ ),
+ titleMedium = TextStyle(
+ fontFamily = Onest,
+ fontWeight = FontWeight.Bold,
+ fontSize = 20.sp,
+ lineHeight = 20.sp,
+ ),
+)
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/HardOffsetShadow.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/HardOffsetShadow.kt
new file mode 100644
index 000000000..9fb9b8a7c
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/HardOffsetShadow.kt
@@ -0,0 +1,32 @@
+package com.firebaseui.android.demo.auth.fullcustomization
+
+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
+
+@Composable
+fun HardOffsetShadow(
+ shape: Shape,
+ modifier: Modifier = Modifier,
+ offsetX: Dp = 3.dp,
+ offsetY: Dp = 6.dp,
+ color: Color = MaterialTheme.colorScheme.primaryContainer,
+ content: @Composable () -> Unit,
+) {
+ Box(modifier = modifier) {
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .offset(x = offsetX, y = offsetY)
+ .background(color = color, shape = shape),
+ )
+ content()
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/LoginStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/LoginStep.kt
new file mode 100644
index 000000000..b3ffa0acf
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/LoginStep.kt
@@ -0,0 +1,188 @@
+package com.firebaseui.android.demo.auth.fullcustomization
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+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.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
+import androidx.compose.material3.Surface
+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.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
+import androidx.compose.ui.text.input.VisualTransformation
+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.firebaseui.android.demo.R
+
+@Composable
+fun LoginStep(
+ state: EmailAuthContentState,
+ onUseDifferentEmail: () -> Unit,
+) {
+ var passwordVisible by remember { mutableStateOf(false) }
+
+ // verticalScroll measures content with infinite max height, and Column distributes weights
+ // against the MIN height when max is infinite (RowColumnMeasurePolicy.kt) — so
+ // heightIn(min = viewport) makes the weighted spacers expand (centering content, anchoring
+ // CTAs to the bottom) when everything fits, and collapse to zero (plain scrolling) when it
+ // doesn't.
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxSize()
+ .safeDrawingPadding(),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .heightIn(min = maxHeight)
+ .padding(horizontal = 40.dp, vertical = 24.dp),
+ ) {
+ 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,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+
+ HardOffsetShadow(shape = AuthFieldShape, modifier = Modifier.fillMaxWidth()) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "email - login card" },
+ color = MaterialTheme.colorScheme.surface,
+ shape = AuthFieldShape,
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ FullCustomizationTextField(
+ value = state.email,
+ onValueChange = {},
+ enabled = false,
+ leadingIcon = { EmailFieldIcon() },
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - email address display" },
+ )
+
+ FullCustomizationTextField(
+ value = state.password,
+ onValueChange = state.onPasswordChange,
+ label = "Password",
+ enabled = !state.isLoading,
+ 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 - password secure input" },
+ )
+
+ Text(
+ text = if (state.resetLinkSent) "Reset link sent!" else "Forgot password?",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.primary,
+ textDecoration = TextDecoration.Underline,
+ textAlign = TextAlign.End,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(enabled = !state.resetLinkSent) {
+ state.onSendResetLinkClick()
+ },
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ CtaButton(
+ text = "Login",
+ onClick = state.onSignInClick,
+ enabled = state.password.isNotBlank() && !state.isLoading,
+ isLoading = state.isLoading,
+ modifier = Modifier.semantics { contentDescription = "button - login" },
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ CtaButton(
+ 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,
+ ),
+ modifier = Modifier.semantics { contentDescription = "button - send login link" },
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = onUseDifferentEmail,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Use a different email")
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/OtherSignInMethodsSheet.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/OtherSignInMethodsSheet.kt
new file mode 100644
index 000000000..0cfa4aaf6
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/OtherSignInMethodsSheet.kt
@@ -0,0 +1,70 @@
+package com.firebaseui.android.demo.auth.fullcustomization
+
+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.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun OtherSignInMethodsSheet(
+ otherProviders: List,
+ onProviderSelected: (AuthProvider) -> Unit,
+ onDismissRequest: () -> Unit,
+ tosUrl: String?,
+ ppUrl: String?,
+) {
+ ModalBottomSheet(
+ onDismissRequest = onDismissRequest,
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 64.dp),
+ ) {
+ Text(
+ text = "Other sign in methods",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 16.dp)
+ .semantics { contentDescription = "Other sign-in methods sheet title" },
+ )
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ otherProviders.forEach { provider ->
+ SheetProviderButton(
+ provider = provider,
+ onClick = {
+ onDismissRequest()
+ onProviderSelected(provider)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ TermsAndPrivacyForm(tosUrl = tosUrl, ppUrl = ppUrl)
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/SheetProviderButton.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/SheetProviderButton.kt
new file mode 100644
index 000000000..4b0f4b243
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/SheetProviderButton.kt
@@ -0,0 +1,120 @@
+package com.firebaseui.android.demo.auth.fullcustomization
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Phone
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextOverflow
+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
+
+@Composable
+fun SheetProviderButton(
+ provider: AuthProvider,
+ onClick: () -> Unit,
+ 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 backgroundColor = if (provider is AuthProvider.Phone) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ style.backgroundColor
+ }
+ val contentColor = if (provider is AuthProvider.Google) Color.Black else style.contentColor
+ val hasWhiteBackground = backgroundColor == Color.White
+
+ Button(
+ onClick = onClick,
+ shape = ProviderButtonShape,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = backgroundColor,
+ contentColor = contentColor,
+ ),
+ border = if (hasWhiteBackground) BorderStroke(1.dp, Color.Black) else null,
+ contentPadding = PaddingValues(horizontal = 36.dp, vertical = 12.dp),
+ modifier = modifier,
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Start,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (provider is AuthProvider.Phone) {
+ Icon(
+ imageVector = Icons.Default.Phone,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ } else {
+ style.icon?.let { icon ->
+ Image(
+ painter = icon.asPainter(),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ text = label,
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 8.dp),
+ maxLines = 1,
+ overflow = TextOverflow.MiddleEllipsis,
+ style = MaterialTheme.typography.labelLarge,
+ )
+ }
+ }
+}
+
+private fun providerSheetLabel(provider: AuthProvider): String = when (provider) {
+ is AuthProvider.Google -> "Sign in with Google"
+ is AuthProvider.Facebook -> "Sign in with Facebook"
+ is AuthProvider.Twitter -> "Sign in with X"
+ is AuthProvider.Github -> "Sign in with GitHub"
+ is AuthProvider.Microsoft -> "Sign in with Microsoft"
+ is AuthProvider.Yahoo -> "Sign in with Yahoo"
+ is AuthProvider.Apple -> "Sign in with Apple"
+ is AuthProvider.Phone -> "Sign in with phone"
+ is AuthProvider.Anonymous -> "Continue as guest"
+ else -> "Continue"
+}
+
+@Composable
+private fun AuthUIAsset.asPainter() = when (this) {
+ is AuthUIAsset.Resource -> painterResource(resId)
+ is AuthUIAsset.Vector -> rememberVectorPainter(image)
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/SignUpStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/SignUpStep.kt
new file mode 100644
index 000000000..a80c134da
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/SignUpStep.kt
@@ -0,0 +1,224 @@
+package com.firebaseui.android.demo.auth.fullcustomization
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.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.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+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
+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.res.painterResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState
+import com.firebaseui.android.demo.R
+
+private val NameFieldStartShape = RoundedCornerShape(
+ topStart = 16.dp,
+ bottomStart = 16.dp,
+ topEnd = 0.dp,
+ bottomEnd = 0.dp,
+)
+private val NameFieldEndShape = RoundedCornerShape(
+ topStart = 0.dp,
+ bottomStart = 0.dp,
+ topEnd = 16.dp,
+ bottomEnd = 16.dp,
+)
+
+@Composable
+fun SignUpStep(
+ state: EmailAuthContentState,
+ onUseDifferentEmail: () -> Unit,
+) {
+ var firstName by remember { mutableStateOf("") }
+ var lastName by remember { mutableStateOf("") }
+ var confirmEmail by remember { mutableStateOf("") }
+
+ val emailsMatch = confirmEmail.isNotBlank() && confirmEmail == state.email
+ val passwordsMatch = state.confirmPassword.isNotBlank() && state.confirmPassword == state.password
+ val canSignUp = firstName.isNotBlank() &&
+ lastName.isNotBlank() &&
+ emailsMatch &&
+ state.password.isNotBlank() &&
+ passwordsMatch &&
+ !state.isLoading
+
+ // verticalScroll measures content with infinite max height, and Column distributes weights
+ // against the MIN height when max is infinite (RowColumnMeasurePolicy.kt) — so
+ // heightIn(min = viewport) makes the weighted spacers expand (centering content, anchoring
+ // CTAs to the bottom) when everything fits, and collapse to zero (plain scrolling) when it
+ // doesn't.
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxSize()
+ .safeDrawingPadding(),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .heightIn(min = maxHeight)
+ .padding(horizontal = 40.dp, vertical = 24.dp),
+ ) {
+ 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,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+
+ HardOffsetShadow(shape = AuthFieldShape, modifier = Modifier.fillMaxWidth()) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "sign up card" },
+ color = MaterialTheme.colorScheme.surface,
+ shape = AuthFieldShape,
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ FullCustomizationTextField(
+ value = firstName,
+ onValueChange = { firstName = it },
+ label = "First name",
+ enabled = !state.isLoading,
+ shape = NameFieldStartShape,
+ modifier = Modifier
+ .weight(1f)
+ .semantics { contentDescription = "text-field - first name" },
+ )
+ FullCustomizationTextField(
+ value = lastName,
+ onValueChange = { lastName = it },
+ label = "Last name",
+ enabled = !state.isLoading,
+ shape = NameFieldEndShape,
+ modifier = Modifier
+ .weight(1f)
+ .semantics { contentDescription = "text-field - last name" },
+ )
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ FullCustomizationTextField(
+ value = state.email,
+ onValueChange = {},
+ enabled = false,
+ leadingIcon = { EmailFieldIcon() },
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - email address display" },
+ )
+ FullCustomizationTextField(
+ value = confirmEmail,
+ onValueChange = { confirmEmail = it },
+ label = "Confirm Email",
+ enabled = !state.isLoading,
+ isError = confirmEmail.isNotBlank() && !emailsMatch,
+ supportingText = if (confirmEmail.isNotBlank() && !emailsMatch) {
+ "Emails don't match"
+ } else {
+ null
+ },
+ leadingIcon = { EmailFieldIcon() },
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - confirm email" },
+ )
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ FullCustomizationTextField(
+ value = state.password,
+ onValueChange = state.onPasswordChange,
+ label = "Password",
+ enabled = !state.isLoading,
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - password" },
+ )
+ FullCustomizationTextField(
+ value = state.confirmPassword,
+ onValueChange = state.onConfirmPasswordChange,
+ label = "Confirm Password",
+ enabled = !state.isLoading,
+ visualTransformation = PasswordVisualTransformation(),
+ isError = state.confirmPassword.isNotBlank() && !passwordsMatch,
+ supportingText = if (state.confirmPassword.isNotBlank() && !passwordsMatch) {
+ "Passwords don't match"
+ } else {
+ null
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - confirm password" },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ CtaButton(
+ text = "Sign up",
+ onClick = {
+ state.onDisplayNameChange("$firstName $lastName".trim())
+ state.onSignUpClick()
+ },
+ enabled = canSignUp,
+ isLoading = state.isLoading,
+ modifier = Modifier.semantics { contentDescription = "button - sign up" },
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = onUseDifferentEmail,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Use a different email")
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/SingleFieldSignInUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/SingleFieldSignInUI.kt
new file mode 100644
index 000000000..a2d21c5ea
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/SingleFieldSignInUI.kt
@@ -0,0 +1,102 @@
+package com.firebaseui.android.demo.auth.fullcustomization
+
+import android.util.Log
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState
+import com.google.firebase.auth.FirebaseAuth
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+
+private enum class FlowStep { EnterEmail, Login, SignUp }
+
+@Composable
+fun SingleFieldSignInUI(
+ state: EmailAuthContentState,
+ otherProviders: List,
+ onProviderSelected: (AuthProvider) -> Unit,
+ tosUrl: String?,
+ ppUrl: String?,
+) {
+ var flowStep by remember { mutableStateOf(FlowStep.EnterEmail) }
+ var showOtherMethods by remember { mutableStateOf(false) }
+ var isCheckingEmail by remember { mutableStateOf(false) }
+ val coroutineScope = rememberCoroutineScope()
+
+ // Password/confirmPassword are hoisted in EmailAuthContentState, not local to LoginStep/
+ // SignUpStep — they survive a round trip back to EnterEmail, so a stale password typed for
+ // one email could carry over if a different email also routes to the same step. Clear them
+ // whenever the user backs out via "Use a different email".
+ val onUseDifferentEmail: () -> Unit = {
+ state.onPasswordChange("")
+ state.onConfirmPasswordChange("")
+ flowStep = FlowStep.EnterEmail
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ when (flowStep) {
+ FlowStep.EnterEmail -> EmailEntryStep(
+ email = state.email,
+ onEmailChange = state.onEmailChange,
+ isLoading = state.isLoading || isCheckingEmail,
+ onContinue = {
+ isCheckingEmail = true
+ coroutineScope.launch {
+ val signInMethods = fetchLegacySignInMethods(state.email)
+ flowStep = if (signInMethods.isEmpty()) FlowStep.SignUp else FlowStep.Login
+ isCheckingEmail = false
+ }
+ },
+ onShowOtherMethods = { showOtherMethods = true },
+ )
+
+ FlowStep.Login -> LoginStep(
+ state = state,
+ onUseDifferentEmail = onUseDifferentEmail,
+ )
+
+ FlowStep.SignUp -> SignUpStep(
+ state = state,
+ onUseDifferentEmail = onUseDifferentEmail,
+ )
+ }
+ }
+
+ if (showOtherMethods) {
+ OtherSignInMethodsSheet(
+ otherProviders = otherProviders,
+ onProviderSelected = onProviderSelected,
+ onDismissRequest = { showOtherMethods = false },
+ tosUrl = tosUrl,
+ ppUrl = ppUrl,
+ )
+ }
+}
+
+/**
+ * Whether [email] is already registered, via Firebase Auth's `fetchSignInMethodsForEmail` —
+ * deprecated by Firebase ("legacy") and, depending on the project's Email Enumeration Protection
+ * setting, may always return an empty list regardless of whether the email exists.
+ */
+private suspend fun fetchLegacySignInMethods(email: String): List {
+ return try {
+ @Suppress("DEPRECATION")
+ FirebaseAuth.getInstance()
+ .fetchSignInMethodsForEmail(email)
+ .await()
+ .signInMethods
+ ?.filter { it.isNotBlank() }
+ ?: emptyList()
+ } catch (e: Exception) {
+ Log.w("SingleFieldSignInUI", "fetchSignInMethodsForEmail failed for $email", e)
+ emptyList()
+ }
+}
diff --git a/app/src/main/res/drawable-xhdpi/email_at_sign.png b/app/src/main/res/drawable-xhdpi/email_at_sign.png
new file mode 100644
index 000000000..f7082d7ad
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/email_at_sign.png differ
diff --git a/app/src/main/res/drawable-xhdpi/full_customization_mascot.png b/app/src/main/res/drawable-xhdpi/full_customization_mascot.png
new file mode 100644
index 000000000..0a5c3afa3
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/full_customization_mascot.png differ
diff --git a/app/src/main/res/drawable/custom_background.png b/app/src/main/res/drawable/custom_background.png
new file mode 100644
index 000000000..ce5dfe236
Binary files /dev/null and b/app/src/main/res/drawable/custom_background.png differ
diff --git a/app/src/main/res/font/bagel_fat_one_regular.ttf b/app/src/main/res/font/bagel_fat_one_regular.ttf
new file mode 100644
index 000000000..9de4a2f78
Binary files /dev/null and b/app/src/main/res/font/bagel_fat_one_regular.ttf differ
diff --git a/app/src/main/res/font/onest_bold.ttf b/app/src/main/res/font/onest_bold.ttf
new file mode 100644
index 000000000..b0a3dd939
Binary files /dev/null and b/app/src/main/res/font/onest_bold.ttf differ
diff --git a/app/src/main/res/font/onest_medium.ttf b/app/src/main/res/font/onest_medium.ttf
new file mode 100644
index 000000000..2ff600481
Binary files /dev/null and b/app/src/main/res/font/onest_medium.ttf differ
diff --git a/app/src/main/res/font/onest_regular.ttf b/app/src/main/res/font/onest_regular.ttf
new file mode 100644
index 000000000..dec9f7a23
Binary files /dev/null and b/app/src/main/res/font/onest_regular.ttf differ
diff --git a/app/src/main/res/font/onest_semibold.ttf b/app/src/main/res/font/onest_semibold.ttf
new file mode 100644
index 000000000..c7e8a3d2e
Binary files /dev/null and b/app/src/main/res/font/onest_semibold.ttf differ
diff --git a/app/src/main/res/font/roboto_bold.ttf b/app/src/main/res/font/roboto_bold.ttf
new file mode 100644
index 000000000..651618564
Binary files /dev/null and b/app/src/main/res/font/roboto_bold.ttf differ
diff --git a/app/src/main/res/font/roboto_medium.ttf b/app/src/main/res/font/roboto_medium.ttf
new file mode 100644
index 000000000..bc5b17026
Binary files /dev/null and b/app/src/main/res/font/roboto_medium.ttf differ
diff --git a/app/src/main/res/font/roboto_regular.ttf b/app/src/main/res/font/roboto_regular.ttf
new file mode 100644
index 000000000..3db0d1fb0
Binary files /dev/null and b/app/src/main/res/font/roboto_regular.ttf differ
diff --git a/app/src/main/res/font/roboto_semibold.ttf b/app/src/main/res/font/roboto_semibold.ttf
new file mode 100644
index 000000000..7a8ef87d5
Binary files /dev/null and b/app/src/main/res/font/roboto_semibold.ttf differ
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt
index fffcf5905..748ccca34 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt
@@ -20,6 +20,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -104,6 +105,14 @@ import kotlinx.coroutines.tasks.await
* @param authenticatedContent Optional slot that allows callers to render the authenticated
* state themselves. When provided, it receives the current [AuthState] alongside an
* [AuthSuccessUiContext] containing common callbacks (sign out, manage MFA, reload user).
+ * @param customMethodPickerLayout Optional slot that fully replaces the method-picker screen.
+ * When provided, it renders as the *entire* screen content — edge-to-edge, with no logo, no
+ * Terms of Service/Privacy Policy footer, and no automatic system-inset handling. The caller is
+ * responsible for its own insets (e.g. `Modifier.safeDrawingPadding()`) and for displaying any
+ * required legal disclosures. [customMethodPickerTermsConfiguration] is ignored when this is set.
+ * @param customMethodPickerTermsConfiguration Optional custom Terms of Service/Privacy Policy
+ * footer for the *default* method-picker layout. Ignored when [customMethodPickerLayout] is
+ * provided, since that slot takes over the whole screen.
*
* @since 10.0.0
*/
@@ -207,19 +216,26 @@ fun FirebaseAuthScreen(
}
) {
composable(AuthRoute.MethodPicker.route) {
- Scaffold { innerPadding ->
- AuthMethodPicker(
- modifier = modifier
- .padding(innerPadding),
- providers = configuration.providers,
- logo = logoAsset,
- termsOfServiceUrl = configuration.tosUrl,
- privacyPolicyUrl = configuration.privacyPolicyUrl,
- lastSignInPreference = lastSignInPreference.value,
- customLayout = customMethodPickerLayout,
- termsConfiguration = customMethodPickerTermsConfiguration,
- onProviderSelected = onProviderSelected,
- )
+ if (customMethodPickerLayout != null) {
+ // Takes over the entire screen — no logo, no ToS/Privacy footer, and no
+ // automatic inset handling. See the KDoc on customMethodPickerLayout.
+ Box(modifier = modifier.fillMaxSize()) {
+ customMethodPickerLayout(configuration.providers, onProviderSelected)
+ }
+ } else {
+ Scaffold { innerPadding ->
+ AuthMethodPicker(
+ modifier = modifier
+ .padding(innerPadding),
+ providers = configuration.providers,
+ logo = logoAsset,
+ termsOfServiceUrl = configuration.tosUrl,
+ privacyPolicyUrl = configuration.privacyPolicyUrl,
+ lastSignInPreference = lastSignInPreference.value,
+ termsConfiguration = customMethodPickerTermsConfiguration,
+ onProviderSelected = onProviderSelected,
+ )
+ }
}
}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt
index bca4bc4c5..c3b5f5b62 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/MfaChallengeDefaults.kt
@@ -27,17 +27,22 @@ import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.firebase.ui.auth.configuration.MfaFactor
+import com.firebase.ui.auth.configuration.string_provider.DefaultAuthUIStringProvider
import com.firebase.ui.auth.configuration.string_provider.LocalAuthUIStringProvider
+import com.firebase.ui.auth.configuration.theme.AuthUITheme
import com.firebase.ui.auth.configuration.validators.VerificationCodeValidator
import com.firebase.ui.auth.mfa.MfaChallengeContentState
import com.firebase.ui.auth.ui.components.VerificationCodeInputField
@@ -50,108 +55,136 @@ internal fun DefaultMfaChallengeContent(state: MfaChallengeContentState) {
VerificationCodeValidator(stringProvider)
}
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .verticalScroll(rememberScrollState())
- .padding(24.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Text(
- text = if (isSms) {
- val phoneLabel = state.maskedPhoneNumber ?: ""
- stringProvider.enterVerificationCodeTitle(phoneLabel)
- } else {
- stringProvider.mfaStepVerifyFactorTitle
- },
- style = MaterialTheme.typography.headlineSmall,
- textAlign = TextAlign.Center
- )
-
- if (isSms && state.maskedPhoneNumber != null) {
- Text(
- text = stringProvider.mfaStepVerifyFactorSmsHelper,
- style = MaterialTheme.typography.bodyMedium,
- textAlign = TextAlign.Center,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
-
- if (state.error != null) {
+ Scaffold { innerPadding ->
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(innerPadding)
+ .verticalScroll(rememberScrollState())
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
Text(
- text = state.error,
- color = MaterialTheme.colorScheme.error,
- style = MaterialTheme.typography.bodySmall,
+ text = if (isSms) {
+ val phoneLabel = state.maskedPhoneNumber ?: ""
+ stringProvider.enterVerificationCodeTitle(phoneLabel)
+ } else {
+ stringProvider.mfaStepVerifyFactorTitle
+ },
+ style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center
)
- }
- Spacer(modifier = Modifier.height(8.dp))
+ if (isSms && state.maskedPhoneNumber != null) {
+ Text(
+ text = stringProvider.mfaStepVerifyFactorSmsHelper,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
- VerificationCodeInputField(
- modifier = Modifier.align(Alignment.CenterHorizontally),
- codeLength = 6,
- validator = verificationCodeValidator,
- isError = state.error != null,
- errorMessage = state.error,
- onCodeChange = state.onVerificationCodeChange
- )
+ if (state.error != null) {
+ Text(
+ text = state.error,
+ color = MaterialTheme.colorScheme.error,
+ style = MaterialTheme.typography.bodySmall,
+ textAlign = TextAlign.Center
+ )
+ }
- Spacer(modifier = Modifier.height(8.dp))
+ Spacer(modifier = Modifier.height(8.dp))
- if (isSms) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- TextButton(
- onClick = { state.onResendCodeClick?.invoke() },
- enabled = state.onResendCodeClick != null && !state.isLoading && state.resendTimer == 0
+ VerificationCodeInputField(
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ codeLength = 6,
+ validator = verificationCodeValidator,
+ isError = state.error != null,
+ errorMessage = state.error,
+ onCodeChange = state.onVerificationCodeChange
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ if (isSms) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
) {
- Text(
- text = if (state.resendTimer > 0) {
- val minutes = state.resendTimer / 60
- val seconds = state.resendTimer % 60
- val formatted = "$minutes:${String.format(java.util.Locale.ROOT, "%02d", seconds)}"
- stringProvider.resendCodeTimer(formatted)
- } else {
- stringProvider.resendCode
- }
- )
- }
+ TextButton(
+ onClick = { state.onResendCodeClick?.invoke() },
+ enabled = state.onResendCodeClick != null && !state.isLoading && state.resendTimer == 0
+ ) {
+ Text(
+ text = if (state.resendTimer > 0) {
+ val minutes = state.resendTimer / 60
+ val seconds = state.resendTimer % 60
+ val formatted = "$minutes:${String.format(java.util.Locale.ROOT, "%02d", seconds)}"
+ stringProvider.resendCodeTimer(formatted)
+ } else {
+ stringProvider.resendCode
+ }
+ )
+ }
- TextButton(
+ TextButton(
+ onClick = state.onCancelClick,
+ enabled = !state.isLoading
+ ) {
+ Text(stringProvider.useDifferentMethodAction)
+ }
+ }
+ } else {
+ OutlinedButton(
onClick = state.onCancelClick,
- enabled = !state.isLoading
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth()
) {
- Text(stringProvider.useDifferentMethodAction)
+ Text(stringProvider.dismissAction)
}
}
- } else {
- OutlinedButton(
- onClick = state.onCancelClick,
- enabled = !state.isLoading,
+
+ Button(
+ onClick = state.onVerifyClick,
+ enabled = state.isValid && !state.isLoading,
modifier = Modifier.fillMaxWidth()
) {
- Text(stringProvider.dismissAction)
+ if (state.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.padding(end = 8.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ Text(stringProvider.verifyAction)
}
}
+ }
+}
- Button(
- onClick = state.onVerifyClick,
- enabled = state.isValid && !state.isLoading,
- modifier = Modifier.fillMaxWidth()
+/**
+ * Renders with a simulated status/nav bar (see CP-240) so the lack of edge-to-edge insets
+ * handling above is visible in the IDE. A plain `@Preview` draws no system chrome at all, so
+ * this bug would be invisible there.
+ */
+@Preview(showSystemUi = true)
+@Composable
+private fun PreviewDefaultMfaChallengeContentEdgeToEdge() {
+ val applicationContext = LocalContext.current
+ val stringProvider = DefaultAuthUIStringProvider(applicationContext)
+
+ AuthUITheme {
+ CompositionLocalProvider(
+ LocalAuthUIStringProvider provides stringProvider
) {
- if (state.isLoading) {
- CircularProgressIndicator(
- modifier = Modifier.padding(end = 8.dp),
- strokeWidth = 2.dp,
- color = MaterialTheme.colorScheme.onPrimary
+ DefaultMfaChallengeContent(
+ state = MfaChallengeContentState(
+ factorType = MfaFactor.Sms,
+ maskedPhoneNumber = "+1••••••890"
)
- }
- Text(stringProvider.verifyAction)
+ )
}
}
}
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt
index 8b73f2c2d..201b5a5f8 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/ResetPasswordUI.kt
@@ -21,7 +21,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.height
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.material.icons.Icons
@@ -137,8 +136,7 @@ fun ResetPasswordUI(
Column(
modifier = Modifier
.padding(innerPadding)
- .safeDrawingPadding()
- .padding(horizontal = 16.dp)
+ .padding(16.dp)
.verticalScroll(rememberScrollState()),
) {
AuthTextField(
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt
index 14e045827..94891e029 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInEmailLinkUI.kt
@@ -21,7 +21,6 @@ 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.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -148,8 +147,7 @@ fun SignInEmailLinkUI(
Column(
modifier = Modifier
.padding(innerPadding)
- .safeDrawingPadding()
- .padding(horizontal = 16.dp)
+ .padding(16.dp)
.verticalScroll(rememberScrollState()),
) {
AuthTextField(
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt
index eabde87a1..b776c7e49 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignInUI.kt
@@ -22,7 +22,6 @@ 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.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
@@ -177,8 +176,7 @@ fun SignInUI(
Column(
modifier = Modifier
.padding(innerPadding)
- .safeDrawingPadding()
- .padding(horizontal = 16.dp)
+ .padding(16.dp)
.verticalScroll(rememberScrollState()),
) {
AuthTextField(
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt
index 6ff0d19f4..da9c9dab7 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/email/SignUpUI.kt
@@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
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.rememberScrollState
@@ -127,8 +126,7 @@ fun SignUpUI(
Column(
modifier = Modifier
.padding(innerPadding)
- .safeDrawingPadding()
- .padding(horizontal = 16.dp)
+ .padding(16.dp)
.verticalScroll(rememberScrollState()),
) {
if (provider.isDisplayNameRequired) {
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt
index ef8bfe545..5e71f4a0a 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterPhoneNumberUI.kt
@@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
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
@@ -106,8 +105,7 @@ fun EnterPhoneNumberUI(
Column(
modifier = Modifier
.padding(innerPadding)
- .safeDrawingPadding()
- .padding(horizontal = 16.dp)
+ .padding(16.dp)
.verticalScroll(rememberScrollState()),
) {
Text(stringProvider.enterPhoneNumberTitle)
diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt
index 122e73a87..b1813e6c0 100644
--- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/phone/EnterVerificationCodeUI.kt
@@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
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
@@ -110,8 +109,7 @@ fun EnterVerificationCodeUI(
Column(
modifier = Modifier
.padding(innerPadding)
- .safeDrawingPadding()
- .padding(horizontal = 16.dp)
+ .padding(16.dp)
.verticalScroll(rememberScrollState()),
) {
Text(