diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index bb74b0a75..d0065cebb 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -6,6 +6,10 @@ plugins {
id("org.jetbrains.kotlin.plugin.compose")
id("com.google.gms.google-services")
id("kotlin-kapt")
+ // The full customization demo hosts its own Navigation 3 back stack for the email flow (see
+ // AuthMethodPickerUI.kt), and its custom NavKey needs the same @Serializable support the auth
+ // module already applies for its own Navigation 3 keys.
+ alias(libs.plugins.kotlin.serialization)
}
android {
@@ -68,6 +72,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 77abc5776..d9ff55383 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,
@@ -181,6 +186,8 @@ fun SpotlightMethodPicker(
val anonymous = groups["anonymous"]?.firstOrNull()
LazyColumn(
+ // customMethodPickerLayout now renders as the entire screen, so this composable is
+ // responsible for its own insets.
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding(),
@@ -298,7 +305,7 @@ fun SpotlightMethodPicker(
}
@Composable
-private fun ProviderIconButton(
+fun ProviderIconButton(
style: AuthUITheme.ProviderStyle,
contentDescription: String,
onClick: () -> Unit,
@@ -335,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
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/FullCustomizationDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationDemoActivity.kt
new file mode 100644
index 000000000..2d7e4ad70
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationDemoActivity.kt
@@ -0,0 +1,163 @@
+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.MfaConfiguration
+import com.firebase.ui.auth.configuration.MfaFactor
+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.firebaseui.android.demo.R
+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
+import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.MfaChallengeUI
+import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.MfaEnrollmentUI
+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
+
+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")
+ },
+ mfaConfiguration = MfaConfiguration(
+ allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp),
+ requireEnrollment = false,
+ ),
+ customMethodPickerLayout = { providers, onProviderSelected ->
+ MainUI(
+ authUI = authUI,
+ configuration = configuration,
+ providers = providers,
+ onProviderSelected = onProviderSelected,
+ )
+ },
+ // The picker hosts its own email entry; this slot covers the email
+ // flows the library navigates to itself (reauth, linking, recovery), which
+ // would otherwise render its stock screen.
+ 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)
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun MainUI(
+ 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()) {
+ // Hosts its own per-mode navigation for the email path; no wrapping EmailAuthScreen
+ // call needed here any more — AuthMethodPickerUI builds one instance per step itself.
+ AuthMethodPickerUI(
+ context = context,
+ configuration = configuration,
+ authUI = authUI,
+ otherProviders = providers.filterNot { it is AuthProvider.Email },
+ onProviderSelected = onProviderSelected,
+ onSuccess = { result ->
+ Log.d("FullCustomizationDemo", "Auth success: ${result.user?.uid}")
+ },
+ onError = { exception ->
+ Log.e("FullCustomizationDemo", "Auth failed", exception)
+ },
+ onCancel = {
+ Log.d("FullCustomizationDemo", "Auth cancelled")
+ },
+ )
+ }
+ }
+}
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
new file mode 100644
index 000000000..b96e8090d
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthPage.kt
@@ -0,0 +1,115 @@
+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
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+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.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
+
+/**
+ * The page frame shared by the MFA and reauthentication screens: mascot, headline, a single
+ * elevated card, and bottom-anchored actions.
+ *
+ * The email and phone steps predate this and inline the same structure themselves.
+ *
+ * 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
+ * actions to the bottom) when everything fits, and collapse to zero (plain scrolling) when it
+ * doesn't.
+ */
+@Composable
+fun AuthPage(
+ @DrawableRes mascot: Int,
+ mascotDescription: String,
+ title: String,
+ cardContentDescription: String,
+ actions: @Composable ColumnScope.() -> Unit,
+ card: @Composable ColumnScope.() -> Unit,
+) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Full-bleed, and deliberately outside the safeDrawingPadding below so it runs edge to
+ // edge under the system bars — same as MainUI and PhoneSignInUI do for their slots.
+ Image(
+ painter = painterResource(id = R.drawable.custom_background),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+
+ 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 = 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,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+
+ HardOffsetShadow(shape = AuthFieldShape, modifier = Modifier.fillMaxWidth()) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = cardContentDescription },
+ color = MaterialTheme.colorScheme.surface,
+ shape = AuthFieldShape,
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ content = card,
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Column(modifier = Modifier.fillMaxWidth(), content = actions)
+ }
+ }
+ }
+}
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
new file mode 100644
index 000000000..ca66ee7bc
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthTextFieldStyle.kt
@@ -0,0 +1,75 @@
+package com.firebaseui.android.demo.auth.fullcustomization.common
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+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,
+ placeholder: 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,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ shape: Shape = AuthFieldShape,
+) {
+ OutlinedTextField(
+ value = value,
+ onValueChange = onValueChange,
+ modifier = modifier,
+ label = label?.let { { Text(it) } },
+ placeholder = placeholder?.let { { Text(it) } },
+ leadingIcon = leadingIcon,
+ trailingIcon = trailingIcon,
+ enabled = enabled,
+ isError = isError,
+ supportingText = supportingText?.let { { Text(it) } },
+ singleLine = singleLine,
+ visualTransformation = visualTransformation,
+ keyboardOptions = keyboardOptions,
+ 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/common/CtaButton.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/CtaButton.kt
new file mode 100644
index 000000000..b147acf12
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/CtaButton.kt
@@ -0,0 +1,59 @@
+package com.firebaseui.android.demo.auth.fullcustomization.common
+
+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
+import com.firebaseui.android.demo.auth.fullcustomization.theme.ButtonShape
+
+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) {
+ // Left on the M3 default (colorScheme.primary). Every caller passes
+ // `enabled = ... && !isLoading`, so the button is disabled exactly while the
+ // spinner shows: the container is the translucent disabled fill, and primary
+ // reads clearly against it. Using LocalContentColor here would instead pick up
+ // disabledContentColor (onSurface at 38%) and wash the spinner out.
+ 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/common/HardOffsetShadow.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/HardOffsetShadow.kt
new file mode 100644
index 000000000..628bffafd
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/HardOffsetShadow.kt
@@ -0,0 +1,32 @@
+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
+
+@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/common/OtherSignInMethodsSheet.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/OtherSignInMethodsSheet.kt
new file mode 100644
index 000000000..dc9efb77d
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/OtherSignInMethodsSheet.kt
@@ -0,0 +1,75 @@
+package com.firebaseui.android.demo.auth.fullcustomization.common
+
+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.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+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,
+ ) {
+ // Scrollable: the demo offers nine alternative providers plus the ToS footer, which
+ // overflows a bottom sheet on shorter screens and in landscape.
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .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/common/SheetProviderButton.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/SheetProviderButton.kt
new file mode 100644
index 000000000..a5d3ca7ff
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/SheetProviderButton.kt
@@ -0,0 +1,124 @@
+package com.firebaseui.android.demo.auth.fullcustomization.common
+
+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
+import com.firebaseui.android.demo.auth.fullcustomization.theme.ProviderButtonShape
+
+@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"
+ // Email only reaches this button during reauthentication: the sign-in sheet filters it out,
+ // since the picker screen already has its own email field.
+ is AuthProvider.Email -> "Continue with email"
+ 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/screens/AuthMethodPickerUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthMethodPickerUI.kt
new file mode 100644
index 000000000..aea21a069
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthMethodPickerUI.kt
@@ -0,0 +1,211 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens
+
+import android.content.Context
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.ContentTransform
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+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.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.compose.dropUnlessResumed
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.entryProvider
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.scene.Scene
+import androidx.navigation3.ui.NavDisplay
+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.auth_provider.AuthProvider
+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.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
+import com.google.firebase.auth.AuthResult
+import kotlinx.serialization.Serializable
+
+/**
+ * The demo's own pre-step, entered before any [AuthRoute.Email.Step]: type an address, then choose
+ * to sign in or create an account. Not part of the library's [AuthRoute] — that sealed hierarchy is
+ * closed outside the auth module — so this is a plain [NavKey] of the demo's own, sharing the same
+ * back stack as the library's per-mode destinations.
+ */
+@Serializable
+private data object EmailEntryKey : NavKey
+
+private fun AuthRoute.Email.Step.toMode(): EmailAuthMode = when (this) {
+ is AuthRoute.Email.SignIn -> EmailAuthMode.SignIn
+ is AuthRoute.Email.SignUp -> EmailAuthMode.SignUp
+ is AuthRoute.Email.ResetPassword -> EmailAuthMode.ResetPassword
+ is AuthRoute.Email.EmailLinkSignIn -> EmailAuthMode.EmailLinkSignIn
+}
+
+private fun stepFor(mode: EmailAuthMode, email: String?): AuthRoute.Email.Step = when (mode) {
+ EmailAuthMode.SignIn -> AuthRoute.Email.SignIn(email)
+ EmailAuthMode.SignUp -> AuthRoute.Email.SignUp(email)
+ EmailAuthMode.ResetPassword -> AuthRoute.Email.ResetPassword(email)
+ EmailAuthMode.EmailLinkSignIn -> AuthRoute.Email.EmailLinkSignIn(email)
+}
+
+/**
+ * Navigates to [target], replacing any existing entry of the same step *type* rather than stacking
+ * a duplicate — matching [com.firebase.ui.auth.ui.screens.email.navigateToEmailStep], which the
+ * library keeps `internal` to its own module. Adds before removing, so no single write leaves the
+ * stack without the chooser at its base.
+ */
+private fun MutableList.navigateToStep(target: AuthRoute.Email.Step) {
+ val existing = indexOfFirst { it is AuthRoute.Email.Step && it::class == target::class }
+ add(target)
+ if (existing >= 0) {
+ while (size > existing + 1) removeAt(existing)
+ }
+}
+
+/** Matches the 700ms cross-fade [FirebaseAuthScreen][com.firebase.ui.auth.ui.screens.FirebaseAuthScreen]
+ * itself falls back to, so a step switch here looks the same as one at the top level. */
+private val EmailStepTransform: AnimatedContentTransitionScope>.() -> ContentTransform = {
+ fadeIn(animationSpec = tween(700)) togetherWith fadeOut(animationSpec = tween(700))
+}
+
+/**
+ * [NavDisplay]'s predictive-back default scales the outgoing step down to 70% while the incoming one
+ * springs in at full size, so a swipe back drew both steps superimposed at different scales. Reuse
+ * the cross-fade above so a gesture back looks like a tapped one.
+ */
+private val EmailStepPredictivePopTransform:
+ AnimatedContentTransitionScope>.(Int) -> ContentTransform =
+ { EmailStepTransform(this) }
+
+/**
+ * Custom UI for `customMethodPickerLayout`'s email path.
+ *
+ * Hosts its own [NavDisplay] over [AuthRoute.Email]'s public per-mode destinations, the same
+ * mechanism [com.firebase.ui.auth.ui.screens.FirebaseAuthScreen] uses for its own hosted
+ * destinations — so switching between sign-in, sign-up, password reset and email-link sign-in
+ * animates, gets a real back-stack entry, and never loses the address the user already typed,
+ * because the address travels as a field on the step's own key rather than in state a switch could
+ * clear.
+ */
+@Composable
+fun AuthMethodPickerUI(
+ context: Context,
+ configuration: AuthUIConfiguration,
+ authUI: FirebaseAuthUI,
+ otherProviders: List,
+ onProviderSelected: (AuthProvider) -> Unit,
+ onSuccess: (AuthResult) -> Unit,
+ onError: (AuthException) -> Unit,
+ onCancel: () -> Unit,
+) {
+ var showOtherMethods by remember { mutableStateOf(false) }
+ val backStack = rememberNavBackStack(EmailEntryKey)
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ NavDisplay(
+ backStack = backStack,
+ transitionSpec = EmailStepTransform,
+ popTransitionSpec = EmailStepTransform,
+ predictivePopTransitionSpec = EmailStepPredictivePopTransform,
+ entryProvider = entryProvider {
+ entry {
+ // Local and disposable: this step performs no auth operation of its own, so
+ // there is nothing here for EmailAuthContentState to own.
+ var email by rememberSaveable { mutableStateOf("") }
+ EmailEntryStep(
+ email = email,
+ onEmailChange = { email = it },
+ isLoading = false,
+ onSignIn = dropUnlessResumed {
+ backStack.navigateToStep(AuthRoute.Email.SignIn(email))
+ },
+ onCreateAccount = dropUnlessResumed {
+ backStack.navigateToStep(AuthRoute.Email.SignUp(email))
+ },
+ onShowOtherMethods = { showOtherMethods = true },
+ )
+ }
+
+ entry { step ->
+ EmailStep(step, backStack, context, configuration, authUI, onSuccess, onError, onCancel)
+ }
+ entry { step ->
+ EmailStep(step, backStack, context, configuration, authUI, onSuccess, onError, onCancel)
+ }
+ entry { step ->
+ EmailStep(step, backStack, context, configuration, authUI, onSuccess, onError, onCancel)
+ }
+ entry { step ->
+ EmailStep(step, backStack, context, configuration, authUI, onSuccess, onError, onCancel)
+ }
+ },
+ )
+ }
+
+ if (showOtherMethods) {
+ OtherSignInMethodsSheet(
+ otherProviders = otherProviders,
+ onProviderSelected = onProviderSelected,
+ onDismissRequest = { showOtherMethods = false },
+ tosUrl = configuration.tosUrl,
+ ppUrl = configuration.privacyPolicyUrl,
+ )
+ }
+}
+
+/**
+ * One [AuthRoute.Email.Step] destination: a single [EmailAuthScreen] instance pinned to [step]'s
+ * mode, seeded with the address [step] carries. [EmailAuthScreen.onNavigateToMode] is what makes
+ * a mode switch push (or replace) an entry on [backStack] instead of mutating local state.
+ */
+@Composable
+private fun EmailStep(
+ step: AuthRoute.Email.Step,
+ backStack: MutableList,
+ context: Context,
+ configuration: AuthUIConfiguration,
+ authUI: FirebaseAuthUI,
+ onSuccess: (AuthResult) -> Unit,
+ onError: (AuthException) -> Unit,
+ onCancel: () -> Unit,
+) {
+ // Resets to the chooser rather than popping one entry: from ResetPassword (reached via
+ // LoginStep's "forgot password" link) the stack is [chooser, SignIn, ResetPassword], and this
+ // is meant to leave the whole in-progress mode, not step back into it.
+ val onUseDifferentEmail: () -> Unit = dropUnlessResumed {
+ backStack.clear()
+ backStack.add(EmailEntryKey)
+ }
+
+ EmailAuthScreen(
+ context = context,
+ configuration = configuration,
+ authUI = authUI,
+ prefillEmail = step.email,
+ mode = step.toMode(),
+ onNavigateToMode = { mode, email -> backStack.navigateToStep(stepFor(mode, email)) },
+ onSuccess = onSuccess,
+ onError = onError,
+ onCancel = onCancel,
+ ) { state ->
+ when (state.mode) {
+ EmailAuthMode.SignUp -> SignUpStep(state, onUseDifferentEmail)
+ // Reset-password and email-link are offered inline on the login form, which also
+ // reports their "sent" states, so every mode has a screen and none can blank out.
+ EmailAuthMode.SignIn,
+ EmailAuthMode.ResetPassword,
+ EmailAuthMode.EmailLinkSignIn -> LoginStep(state, onUseDifferentEmail)
+ }
+ }
+}
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
new file mode 100644
index 000000000..ce7fcff51
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthenticatedUI.kt
@@ -0,0 +1,288 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens
+
+import android.util.Log
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+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.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.platform.LocalContext
+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.unit.dp
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import com.firebase.ui.auth.AuthState
+import com.firebase.ui.auth.ui.screens.AuthSuccessUiContext
+import com.firebase.ui.auth.util.displayIdentifier
+import com.firebase.ui.auth.util.getDisplayEmail
+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 kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+
+private const val TAG = "FullCustomizationDemo"
+
+/**
+ * Custom UI for `FirebaseAuthScreen.authenticatedContent`.
+ *
+ * Its main job in this demo is making the other slots reachable: the two-factor button navigates to
+ * the flow that `mfaEnrollmentContent` renders, and changing the password is a sensitive operation,
+ * so wrapping it in [com.firebase.ui.auth.FirebaseAuthUI.withReauth] is what provokes
+ * `reauthContent`.
+ *
+ * This slot also receives the email-verification and profile-completion states, which the library
+ * would otherwise render itself — so they are handled here too rather than falling through to a
+ * blank screen.
+ */
+@Composable
+fun AuthenticatedUI(state: AuthState, uiContext: AuthSuccessUiContext) {
+ when (state) {
+ is AuthState.RequiresEmailVerification -> VerifyEmailPage(uiContext)
+ is AuthState.RequiresProfileCompletion -> ProfileCompletionPage(state, uiContext)
+ else -> SignedInPage(uiContext)
+ }
+}
+
+@Composable
+private fun SignedInPage(uiContext: AuthSuccessUiContext) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val authUI = uiContext.authUI
+ // Read on every recomposition rather than remembering: the identifier has to follow the
+ // current user, which changes across sign-out and reauth.
+ val identifier = authUI.getCurrentUser().displayIdentifier()
+
+ // enrolledFactors reads the cached user, so it still shows the pre-enrollment list when we come
+ // back from the MFA flow. This destination is disposed while that flow is on screen, so the
+ // effect re-runs on return and refreshes it; keyed on Unit, it can't loop on its own update.
+ LaunchedEffect(Unit) { uiContext.onReloadUser() }
+ val enrolledFactors = authUI.getCurrentUser()?.multiFactor?.enrolledFactors.orEmpty()
+
+ var newPassword by remember { mutableStateOf("") }
+ var passwordVisible by remember { mutableStateOf(false) }
+ var isUpdating by remember { mutableStateOf(false) }
+ var statusMessage by remember { mutableStateOf(null) }
+ var isError by remember { mutableStateOf(false) }
+
+ AuthPage(
+ mascot = R.drawable.full_customization_mascot,
+ mascotDescription = "doggo - cute welcome mascot",
+ title = "You're in",
+ cardContentDescription = "authenticated - account card",
+ card = {
+ Text(
+ text = if (identifier.isNotBlank()) "Signed in as $identifier" else "Signed in",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.primary,
+ )
+
+ Text(
+ text = "Changing your password needs a recent sign-in, so it triggers the custom " +
+ "reauth screen.",
+ style = MaterialTheme.typography.bodyLarge,
+ 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" },
+ )
+ },
+ actions = {
+ CtaButton(
+ text = "Change password",
+ onClick = {
+ // lifecycleScope rather than rememberCoroutineScope: the reauth overlay
+ // replaces this screen mid-flight, and the retried operation has to outlive 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: Exception) {
+ Log.e(TAG, "Password change failed", e)
+ isError = true
+ statusMessage = "Couldn't change the password. Try again."
+ } finally {
+ isUpdating = false
+ }
+ }
+ },
+ enabled = newPassword.length >= 6 && !isUpdating,
+ isLoading = isUpdating,
+ modifier = Modifier.semantics { contentDescription = "button - change password" },
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ CtaButton(
+ // Relabelled rather than disabled: SelectFactorStep is the only place a factor can
+ // be removed, so greying this out once one exists would strand the user with it.
+ 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,
+ ),
+ modifier = Modifier.semantics { contentDescription = "button - manage mfa" },
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = uiContext.onSignOut,
+ enabled = !isUpdating,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(uiContext.stringProvider.signOutAction)
+ }
+ },
+ )
+}
+
+@Composable
+private fun VerifyEmailPage(uiContext: AuthSuccessUiContext) {
+ val stringProvider = uiContext.stringProvider
+ val user = uiContext.authUI.getCurrentUser()
+ val emailLabel = user.getDisplayEmail(stringProvider.emailProvider)
+
+ AuthPage(
+ mascot = R.drawable.full_customization_mascot,
+ mascotDescription = "doggo - cute welcome mascot",
+ title = "Check your inbox",
+ cardContentDescription = "authenticated - verify email card",
+ card = {
+ Text(
+ text = stringProvider.verifyEmailInstruction(emailLabel),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ actions = {
+ CtaButton(
+ text = stringProvider.verifiedEmailAction,
+ onClick = uiContext.onReloadUser,
+ modifier = Modifier.semantics {
+ contentDescription = "button - recheck email verification"
+ },
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ CtaButton(
+ text = stringProvider.resendVerificationEmailAction,
+ onClick = { user?.sendEmailVerification() },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ modifier = Modifier.semantics {
+ contentDescription = "button - resend verification email"
+ },
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = uiContext.onSignOut,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringProvider.signOutAction)
+ }
+ },
+ )
+}
+
+@Composable
+private fun ProfileCompletionPage(
+ state: AuthState.RequiresProfileCompletion,
+ uiContext: AuthSuccessUiContext,
+) {
+ val stringProvider = uiContext.stringProvider
+
+ AuthPage(
+ mascot = R.drawable.full_customization_mascot,
+ mascotDescription = "doggo - cute welcome mascot",
+ title = "Almost there",
+ cardContentDescription = "authenticated - profile completion card",
+ card = {
+ Text(
+ text = stringProvider.profileCompletionMessage,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ if (state.missingFields.isNotEmpty()) {
+ Text(
+ text = stringProvider.profileMissingFieldsMessage(
+ state.missingFields.joinToString()
+ ),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ },
+ actions = {
+ TextButton(
+ onClick = uiContext.onSignOut,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(stringProvider.signOutAction)
+ }
+ },
+ )
+}
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
new file mode 100644
index 000000000..71b4bc66e
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/EmailAuthUI.kt
@@ -0,0 +1,86 @@
+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
+import androidx.compose.runtime.getValue
+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.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
+import com.firebaseui.android.demo.auth.fullcustomization.screens.reauth.ReauthEmailStep
+
+/**
+ * Custom UI for `FirebaseAuthScreen.emailContent`.
+ *
+ * The method picker hosts its own email entry, so this slot only renders for email flows the
+ * *library* navigates to: reauthentication, account linking, and email-already-in-use recovery.
+ * Without it those flows fall back to the library's stock email screen, which is jarring inside a
+ * demo whose whole premise is that nothing looks stock.
+ *
+ * An address supplied by the library (as reauthentication does) skips the choice entirely — the
+ * caller already knows who is signing in.
+ */
+@Composable
+fun EmailAuthUI(state: EmailAuthContentState) {
+ // isEmailLocked is only ever true in reauthentication mode (EmailAuthScreen sets it from
+ // isReauthenticationMode and a prefilled address), and reauth composes this slot inside a modal
+ // bottom sheet, so it needs its own sheet-shaped screen rather than the sign-in page.
+ if (state.isEmailLocked) {
+ ReauthEmailStep(state)
+ return
+ }
+
+ var chosen by rememberSaveable { mutableStateOf(state.email.isNotBlank()) }
+
+ val onUseDifferentEmail: () -> Unit = {
+ state.onPasswordChange("")
+ state.onConfirmPasswordChange("")
+ chosen = false
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ // The email pages don't paint their own background — MainUI and PhoneSignInUI do it for
+ // theirs — so this slot has to, or the screen renders on bare surface colour.
+ Image(
+ painter = painterResource(id = R.drawable.custom_background),
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+
+ if (!chosen) {
+ EmailEntryStep(
+ email = state.email,
+ onEmailChange = state.onEmailChange,
+ isLoading = state.isLoading,
+ onSignIn = {
+ state.onGoToSignIn()
+ chosen = true
+ },
+ onCreateAccount = {
+ state.onGoToSignUp()
+ chosen = true
+ },
+ // No provider sheet in this slot — the caller already committed to email.
+ onShowOtherMethods = {},
+ )
+ } else {
+ when (state.mode) {
+ EmailAuthMode.SignUp -> SignUpStep(state, onUseDifferentEmail)
+ EmailAuthMode.SignIn,
+ EmailAuthMode.ResetPassword,
+ EmailAuthMode.EmailLinkSignIn -> LoginStep(state, onUseDifferentEmail)
+ }
+ }
+ }
+}
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
new file mode 100644
index 000000000..a4fd2eb4d
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/EmailEntryStep.kt
@@ -0,0 +1,204 @@
+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
+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.ButtonDefaults
+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
+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.theme.IntroShape
+
+@Composable
+fun EmailEntryStep(
+ email: String,
+ onEmailChange: (String) -> Unit,
+ isLoading: Boolean,
+ onSignIn: () -> Unit,
+ onCreateAccount: () -> 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))
+
+ // Two explicit choices rather than one "Continue" that guesses: with email
+ // enumeration protection enabled, Firebase deliberately withholds whether an
+ // address is registered, so asking is the only reliable route.
+ CtaButton(
+ text = "Sign in",
+ onClick = onSignIn,
+ enabled = isEmailValid && !isLoading,
+ isLoading = isLoading,
+ modifier = Modifier.semantics { contentDescription = "button - sign in" },
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ CtaButton(
+ text = "Create account",
+ onClick = onCreateAccount,
+ // Not gated on the address: the sign-up form collects and confirms it, so
+ // there is nothing to validate here first.
+ enabled = !isLoading,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ modifier = Modifier.semantics { contentDescription = "button - create account" },
+ )
+ }
+
+ 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/screens/email/pages/LoginStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/LoginStep.kt
new file mode 100644
index 000000000..f26a83260
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/LoginStep.kt
@@ -0,0 +1,193 @@
+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
+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
+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
+
+@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/screens/email/pages/SignUpStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/SignUpStep.kt
new file mode 100644
index 000000000..d90243179
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/SignUpStep.kt
@@ -0,0 +1,244 @@
+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
+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.text.KeyboardOptions
+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.KeyboardType
+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
+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
+
+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("") }
+
+ // Compared case-insensitively and trimmed: this field uses the default keyboard, which
+ // auto-capitalises on many IMEs, so an exact match would reject the user's own address.
+ val emailsMatch = confirmEmail.isNotBlank() &&
+ confirmEmail.trim().equals(state.email.trim(), ignoreCase = true)
+ 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,
+ // Editable here, unlike the login form: the account doesn't
+ // exist yet, and "Create account" can be reached without
+ // having typed an address on the previous screen.
+ onValueChange = state.onEmailChange,
+ label = "Email",
+ enabled = !state.isLoading,
+ leadingIcon = { EmailFieldIcon() },
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Email,
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - email address display" },
+ )
+ FullCustomizationTextField(
+ value = confirmEmail,
+ onValueChange = { confirmEmail = it },
+ label = "Confirm Email",
+ enabled = !state.isLoading,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Email,
+ ),
+ 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/screens/mfa/MfaChallengeUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/MfaChallengeUI.kt
new file mode 100644
index 000000000..741f41882
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/MfaChallengeUI.kt
@@ -0,0 +1,101 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.mfa
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+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.MfaFactor
+import com.firebase.ui.auth.mfa.MfaChallengeContentState
+import com.firebase.ui.auth.ui.components.VerificationCodeInputField
+import com.firebaseui.android.demo.R
+import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage
+import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton
+
+/**
+ * Custom UI for `FirebaseAuthScreen.mfaChallengeContent` — the second-factor prompt shown during
+ * sign-in when the account has MFA enrolled.
+ */
+@Composable
+fun MfaChallengeUI(state: MfaChallengeContentState) {
+ val isSms = state.factorType == MfaFactor.Sms
+
+ AuthPage(
+ mascot = if (isSms) {
+ R.drawable.full_customization_phone_mascot
+ } else {
+ R.drawable.full_customization_mascot
+ },
+ mascotDescription = "doggo - cute two-factor mascot",
+ title = "One more step",
+ cardContentDescription = "mfa - challenge card",
+ card = {
+ Text(
+ text = if (isSms) {
+ "We sent a code to ${state.maskedPhoneNumber ?: "your phone"}."
+ } else {
+ "Open your authenticator app and enter the 6-digit code for this account."
+ },
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ VerificationCodeInputField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - mfa challenge code input" },
+ isError = state.hasError,
+ errorMessage = state.error,
+ onCodeChange = state.onVerificationCodeChange,
+ )
+
+ // canResend already covers "SMS factor and a resend callback exists".
+ if (state.canResend) {
+ Text(
+ text = if (state.resendTimer > 0) {
+ "Resend code in ${state.resendTimer}s"
+ } else {
+ "Resend code"
+ },
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.primary,
+ textAlign = TextAlign.End,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(enabled = state.resendTimer == 0 && !state.isLoading) {
+ state.onResendCodeClick?.invoke()
+ },
+ )
+ }
+ },
+ actions = {
+ CtaButton(
+ text = "Verify",
+ onClick = state.onVerifyClick,
+ enabled = state.isValid && !state.isLoading,
+ isLoading = state.isLoading,
+ modifier = Modifier.semantics {
+ contentDescription = "button - verify mfa challenge"
+ },
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = state.onCancelClick,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Cancel")
+ }
+ },
+ )
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/MfaEnrollmentUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/MfaEnrollmentUI.kt
new file mode 100644
index 000000000..f104c1bb0
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/MfaEnrollmentUI.kt
@@ -0,0 +1,25 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.mfa
+
+import androidx.compose.runtime.Composable
+import com.firebase.ui.auth.mfa.MfaEnrollmentContentState
+import com.firebase.ui.auth.mfa.MfaEnrollmentStep
+import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages.ConfigureSmsStep
+import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages.ConfigureTotpStep
+import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages.SelectFactorStep
+import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages.VerifyFactorStep
+
+/**
+ * Custom UI for `FirebaseAuthScreen.mfaEnrollmentContent`.
+ *
+ * A single state object drives every enrollment step, so this only dispatches on
+ * [MfaEnrollmentContentState.step] — the library owns the step transitions.
+ */
+@Composable
+fun MfaEnrollmentUI(state: MfaEnrollmentContentState) {
+ when (state.step) {
+ MfaEnrollmentStep.SelectFactor -> SelectFactorStep(state)
+ MfaEnrollmentStep.ConfigureSms -> ConfigureSmsStep(state)
+ MfaEnrollmentStep.ConfigureTotp -> ConfigureTotpStep(state)
+ MfaEnrollmentStep.VerifyFactor -> VerifyFactorStep(state)
+ }
+}
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
new file mode 100644
index 000000000..de9a85cab
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureSmsStep.kt
@@ -0,0 +1,124 @@
+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.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+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.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.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.input.KeyboardType
+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
+
+@Composable
+fun ConfigureSmsStep(state: MfaEnrollmentContentState) {
+ AuthPage(
+ mascot = R.drawable.full_customization_phone_mascot,
+ mascotDescription = "doggo - cute phone sign-in mascot",
+ title = "Add your number",
+ cardContentDescription = "mfa - sms setup card",
+ card = {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ // CountrySelector needs a non-null country; the library's own default UI skips the
+ // whole step while the country is still resolving, so match that.
+ state.selectedCountry?.let { country ->
+ Surface(
+ color = Color.White,
+ shape = AuthFieldShape,
+ modifier = Modifier
+ .requiredHeight(56.dp)
+ .border(
+ width = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant,
+ shape = AuthFieldShape,
+ )
+ .semantics { contentDescription = "country code selector" },
+ ) {
+ CountrySelector(
+ selectedCountry = country,
+ onCountrySelected = state.onCountrySelected,
+ enabled = !state.isLoading,
+ )
+ }
+ }
+
+ FullCustomizationTextField(
+ value = state.phoneNumber,
+ onValueChange = state.onPhoneNumberChange,
+ placeholder = "Phone number",
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Phone,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ enabled = !state.isLoading,
+ isError = state.hasError,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
+ modifier = Modifier
+ .weight(1f)
+ .semantics { contentDescription = "text-field - mfa phone number input" },
+ )
+ }
+
+ Text(
+ text = state.error
+ ?: "We'll text a code to this number whenever you sign in. " +
+ "Message & data rates may apply.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (state.hasError) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ },
+ actions = {
+ 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) {
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = state.onBackClick,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("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
new file mode 100644
index 000000000..562fe7919
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureTotpStep.kt
@@ -0,0 +1,102 @@
+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
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+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.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.AuthPage
+import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton
+
+@Composable
+fun ConfigureTotpStep(state: MfaEnrollmentContentState) {
+ AuthPage(
+ mascot = R.drawable.full_customization_mascot,
+ mascotDescription = "doggo - cute security mascot",
+ title = "Scan to set up",
+ cardContentDescription = "mfa - totp setup card",
+ card = {
+ Text(
+ text = "Scan this with your authenticator app, or type the key in by hand.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ state.totpQrCodeUrl?.let { url ->
+ QrCodeImage(
+ content = url,
+ size = 200.dp,
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .border(
+ width = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant,
+ shape = AuthFieldShape,
+ )
+ .padding(12.dp)
+ .semantics { contentDescription = "mfa - totp qr code" },
+ )
+ }
+
+ state.totpSecret?.sharedSecretKey?.let { key ->
+ SelectionContainer {
+ Text(
+ text = key,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "mfa - totp shared secret key" },
+ )
+ }
+ }
+
+ state.error?.let { error ->
+ Text(
+ text = error,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ },
+ actions = {
+ CtaButton(
+ text = "I've added it",
+ onClick = state.onContinueToVerifyClick,
+ enabled = state.isValid && !state.isLoading,
+ isLoading = state.isLoading,
+ modifier = Modifier.semantics {
+ contentDescription = "button - continue to mfa verification"
+ },
+ )
+
+ if (state.canGoBack) {
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = state.onBackClick,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Pick a different method")
+ }
+ }
+ },
+ )
+}
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
new file mode 100644
index 000000000..676ad465c
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/SelectFactorStep.kt
@@ -0,0 +1,142 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages
+
+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.material3.ButtonDefaults
+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.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.MfaFactor
+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.google.firebase.auth.MultiFactorInfo
+import com.google.firebase.auth.PhoneMultiFactorGenerator
+import com.google.firebase.auth.TotpMultiFactorGenerator
+
+@Composable
+fun SelectFactorStep(state: MfaEnrollmentContentState) {
+ AuthPage(
+ mascot = R.drawable.full_customization_mascot,
+ mascotDescription = "doggo - cute security mascot",
+ title = "Secure your account",
+ cardContentDescription = "mfa - factor selection card",
+ card = {
+ Text(
+ text = "Add a second step to sign-in, so a password on its own isn't enough.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ state.error?.let { error ->
+ Text(
+ text = error,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+
+ if (state.enrolledFactors.isNotEmpty()) {
+ HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
+
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = "Already on this account",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.primary,
+ )
+
+ state.enrolledFactors.forEach { info ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = enrolledFactorLabel(info),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.weight(1f),
+ )
+ TextButton(
+ onClick = { state.onUnenrollFactor(info) },
+ enabled = !state.isLoading,
+ modifier = Modifier.semantics {
+ contentDescription =
+ "button - remove factor ${enrolledFactorLabel(info)}"
+ },
+ ) {
+ Text("Remove")
+ }
+ }
+ }
+ }
+ }
+ },
+ actions = {
+ state.availableFactors.forEachIndexed { index, factor ->
+ if (index > 0) Spacer(modifier = Modifier.height(16.dp))
+
+ CtaButton(
+ text = factorCtaLabel(factor),
+ onClick = { state.onFactorSelected(factor) },
+ enabled = !state.isLoading,
+ // The first factor carries the primary CTA colour; the rest read as
+ // alternatives, matching how LoginStep tiers its two CTAs.
+ colors = if (index == 0) {
+ ButtonDefaults.buttonColors()
+ } else {
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ },
+ modifier = Modifier.semantics {
+ contentDescription = "button - enroll ${factorCtaLabel(factor)}"
+ },
+ )
+ }
+
+ state.onSkipClick?.let { onSkip ->
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = onSkip,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Not now")
+ }
+ }
+ },
+ )
+}
+
+private fun factorCtaLabel(factor: MfaFactor): String = when (factor) {
+ MfaFactor.Sms -> "Use text message"
+ MfaFactor.Totp -> "Use an authenticator app"
+}
+
+/**
+ * SMS factors carry the phone number as their display name; TOTP factors are often unnamed, so
+ * fall back to the factor id.
+ */
+private fun enrolledFactorLabel(info: MultiFactorInfo): String {
+ val fallback = when (info.factorId) {
+ PhoneMultiFactorGenerator.FACTOR_ID -> "Text message"
+ TotpMultiFactorGenerator.FACTOR_ID -> "Authenticator app"
+ else -> info.factorId
+ }
+ return info.displayName?.takeIf { it.isNotBlank() } ?: fallback
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/VerifyFactorStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/VerifyFactorStep.kt
new file mode 100644
index 000000000..351c73cfc
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/VerifyFactorStep.kt
@@ -0,0 +1,100 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+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.MfaFactor
+import com.firebase.ui.auth.mfa.MfaEnrollmentContentState
+import com.firebase.ui.auth.ui.components.VerificationCodeInputField
+import com.firebaseui.android.demo.R
+import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage
+import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton
+
+@Composable
+fun VerifyFactorStep(state: MfaEnrollmentContentState) {
+ val isSms = state.selectedFactor == MfaFactor.Sms
+ val fullPhoneNumber = "${state.selectedCountry?.dialCode ?: ""}${state.phoneNumber}"
+
+ AuthPage(
+ mascot = if (isSms) {
+ R.drawable.full_customization_phone_mascot
+ } else {
+ R.drawable.full_customization_mascot
+ },
+ mascotDescription = "doggo - cute two-factor mascot",
+ title = "Confirm the code",
+ cardContentDescription = "mfa - enrollment verification card",
+ card = {
+ Text(
+ text = if (isSms) {
+ "We sent a code to $fullPhoneNumber."
+ } else {
+ "Enter the 6-digit code your authenticator app is showing right now."
+ },
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ VerificationCodeInputField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - mfa enrollment code input" },
+ isError = state.hasError,
+ errorMessage = state.error,
+ onCodeChange = state.onVerificationCodeChange,
+ )
+
+ // onResendCodeClick is null for TOTP, where there is nothing to resend.
+ state.onResendCodeClick?.let { onResend ->
+ Text(
+ text = if (state.resendTimer > 0) {
+ "Resend code in ${state.resendTimer}s"
+ } else {
+ "Resend code"
+ },
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.primary,
+ textAlign = TextAlign.End,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(enabled = state.resendTimer == 0 && !state.isLoading) {
+ onResend()
+ },
+ )
+ }
+ },
+ actions = {
+ CtaButton(
+ text = "Verify",
+ onClick = state.onVerifyClick,
+ enabled = state.isValid && !state.isLoading,
+ isLoading = state.isLoading,
+ modifier = Modifier.semantics {
+ contentDescription = "button - verify mfa enrollment"
+ },
+ )
+
+ if (state.canGoBack) {
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = state.onBackClick,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Back")
+ }
+ }
+ },
+ )
+}
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
new file mode 100644
index 000000000..ecf4bc41a
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/PhoneSignInUI.kt
@@ -0,0 +1,30 @@
+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.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(),
+ )
+ 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
new file mode 100644
index 000000000..38289f1d1
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneEntryStep.kt
@@ -0,0 +1,166 @@
+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
+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.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
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Phone
+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.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
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.ui.components.CountrySelector
+import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState
+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.FullCustomizationTextField
+import com.firebaseui.android.demo.auth.fullcustomization.common.HardOffsetShadow
+
+@Composable
+fun PhoneEntryStep(state: PhoneAuthContentState) {
+ val isPhoneValid = remember(state.phoneNumber) {
+ android.util.Patterns.PHONE.matcher(state.phoneNumber).matches()
+ }
+
+ // 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 CTA 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_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,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+
+ HardOffsetShadow(shape = AuthFieldShape, modifier = Modifier.fillMaxWidth()) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "phone - sign in card" },
+ color = MaterialTheme.colorScheme.surface,
+ shape = AuthFieldShape,
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Surface(
+ color = Color.White,
+ shape = AuthFieldShape,
+ modifier = Modifier
+ .requiredHeight(56.dp)
+ .border(
+ width = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant,
+ shape = AuthFieldShape,
+ )
+ .semantics { contentDescription = "country code selector" },
+ ) {
+ CountrySelector(
+ selectedCountry = state.selectedCountry,
+ onCountrySelected = state.onCountrySelected,
+ enabled = !state.isLoading,
+ )
+ }
+
+ FullCustomizationTextField(
+ value = state.phoneNumber,
+ onValueChange = state.onPhoneNumberChange,
+ placeholder = "Phone number",
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Phone,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ enabled = !state.isLoading,
+ isError = state.error != null,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
+ modifier = Modifier
+ .weight(1f)
+ .semantics { contentDescription = "text-field - phone number input" },
+ )
+ }
+
+ Text(
+ text = state.error
+ ?: "By signing in with phone number, an SMS may be sent. " +
+ "Message & data rates may apply.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (state.error != null) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.height(24.dp))
+
+ CtaButton(
+ text = "Sign Up",
+ onClick = state.onSendCodeClick,
+ enabled = isPhoneValid && !state.isLoading,
+ isLoading = state.isLoading,
+ modifier = Modifier.semantics { contentDescription = "button - send verification code" },
+ )
+ }
+ }
+}
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
new file mode 100644
index 000000000..8e9a15318
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneVerificationStep.kt
@@ -0,0 +1,137 @@
+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
+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.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.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
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.ui.components.VerificationCodeInputField
+import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState
+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
+
+@Composable
+fun PhoneVerificationStep(state: PhoneAuthContentState) {
+ 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_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,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+
+ HardOffsetShadow(shape = AuthFieldShape, modifier = Modifier.fillMaxWidth()) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "phone - verification card" },
+ color = MaterialTheme.colorScheme.surface,
+ shape = AuthFieldShape,
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ text = "We sent a code to ${state.fullPhoneNumber}.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ VerificationCodeInputField(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - verification code input" },
+ isError = state.error != null,
+ errorMessage = state.error,
+ onCodeChange = state.onVerificationCodeChange,
+ )
+
+ Text(
+ text = if (state.resendTimer > 0) {
+ "Resend code in ${state.resendTimer}s"
+ } else {
+ "Resend code"
+ },
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.primary,
+ textAlign = TextAlign.End,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(enabled = state.resendTimer == 0) {
+ state.onResendCodeClick()
+ },
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ 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(8.dp))
+
+ TextButton(
+ onClick = state.onChangeNumberClick,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Use a different number")
+ }
+ }
+ }
+ }
+}
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
new file mode 100644
index 000000000..d22ee432b
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/reauth/ReauthEmailStep.kt
@@ -0,0 +1,143 @@
+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
+import androidx.compose.foundation.layout.fillMaxWidth
+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
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+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.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.text.style.TextDecoration
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+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.CtaButton
+import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField
+
+/**
+ * The email step of reauthentication, for `FirebaseAuthScreen.emailContent` when the library has
+ * locked the address.
+ *
+ * Sign-in's own page is the wrong screen here twice over. The library composes the reauth email
+ * step inside a modal bottom sheet, so a full-bleed background and a viewport-height layout fight
+ * the sheet rather than sit in it; and reauthentication turns off sign-up and email-link sign-in
+ * (`isEmailSignUpOffered`/`isEmailLinkSignInOffered` both return false in that mode), so the
+ * affordances that page offers alongside the password are dead. This is the one thing the user can
+ * actually do: confirm the password for an address they cannot change.
+ *
+ * Password reset stays, because it still works — the link is sent while the sheet is up.
+ */
+@Composable
+fun ReauthEmailStep(state: EmailAuthContentState) {
+ var passwordVisible by remember { mutableStateOf(false) }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .imePadding()
+ .padding(horizontal = 24.dp)
+ .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,
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = "Enter the password for ${state.email}.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ FullCustomizationTextField(
+ value = state.password,
+ onValueChange = state.onPasswordChange,
+ label = "Password",
+ enabled = !state.isLoading,
+ isError = state.error != null,
+ supportingText = state.error,
+ 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 - reauth password secure input" },
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ 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.isLoading) {
+ state.onSendResetLinkClick()
+ },
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ CtaButton(
+ text = "Confirm",
+ onClick = state.onSignInClick,
+ enabled = state.password.isNotBlank() && !state.isLoading,
+ isLoading = state.isLoading,
+ modifier = Modifier.semantics { contentDescription = "button - confirm reauth" },
+ )
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/reauth/ReauthUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/reauth/ReauthUI.kt
new file mode 100644
index 000000000..b3cd6f8a3
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/reauth/ReauthUI.kt
@@ -0,0 +1,93 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.reauth
+
+import androidx.activity.compose.BackHandler
+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.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.ui.screens.reauth.ReauthContentState
+import com.firebaseui.android.demo.R
+import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage
+import com.firebaseui.android.demo.auth.fullcustomization.common.SheetProviderButton
+
+/**
+ * Custom UI for `FirebaseAuthScreen.reauthContent`.
+ *
+ * [ReauthContentState.providers] arrives already filtered to the providers linked to this user, and
+ * [ReauthContentState.onProviderSelected] performs the credential exchange, so this is purely a
+ * chooser: the library owns the reauthentication itself and the dismiss/retry sequencing that
+ * follows it. Picking email or phone hands off to the library's own sub-flow.
+ */
+@Composable
+fun ReauthUI(state: ReauthContentState) {
+ // The slot renders as an overlay outside the NavHost, so nothing else consumes the system back
+ // press — without this it would fall through and finish the Activity mid-reauthentication.
+ BackHandler(enabled = !state.isLoading) { state.onDismiss() }
+
+ AuthPage(
+ mascot = R.drawable.full_customization_mascot,
+ mascotDescription = "doggo - cute welcome mascot",
+ title = "Is that you?",
+ cardContentDescription = "reauth - provider chooser card",
+ card = {
+ Text(
+ text = state.reason
+ ?: "Confirm it's you to continue with ${state.user.email ?: "this account"}.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ state.error?.let { error ->
+ Text(
+ text = error,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+
+ if (state.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .semantics { contentDescription = "reauth - in progress" },
+ )
+ }
+ },
+ actions = {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ state.providers.forEach { provider ->
+ SheetProviderButton(
+ provider = provider,
+ onClick = { state.onProviderSelected(provider) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics {
+ contentDescription = "button - reauth with ${provider.providerName}"
+ },
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = state.onDismiss,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Cancel")
+ }
+ },
+ )
+}
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
new file mode 100644
index 000000000..483b0b741
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationShapes.kt
@@ -0,0 +1,8 @@
+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/FullCustomizationTheme.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTheme.kt
new file mode 100644
index 000000000..53b916119
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTheme.kt
@@ -0,0 +1,135 @@
+package com.firebaseui.android.demo.auth.fullcustomization.theme
+
+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/theme/FullCustomizationTypography.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTypography.kt
new file mode 100644
index 000000000..da31b8bc2
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTypography.kt
@@ -0,0 +1,59 @@
+package com.firebaseui.android.demo.auth.fullcustomization.theme
+
+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/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-xhdpi/full_customization_phone_mascot.png b/app/src/main/res/drawable-xhdpi/full_customization_phone_mascot.png
new file mode 100644
index 000000000..6dee4a852
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/full_customization_phone_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 ec323522d..3734f77b6 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
@@ -354,6 +354,8 @@ fun FirebaseAuthScreen(
metadata = authRouteMetadata(AuthRoute.MethodPicker)
) {
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)
}
diff --git a/storage/build.gradle.kts b/storage/build.gradle.kts
index c45b44a63..69d7c31c8 100644
--- a/storage/build.gradle.kts
+++ b/storage/build.gradle.kts
@@ -56,4 +56,4 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockito.core)
-}
\ No newline at end of file
+}