diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index bb74b0a75..abcf1afd2 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -68,6 +68,7 @@ dependencies {
implementation(libs.compose.ui.graphics)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
+ implementation(libs.compose.material.icons.extended)
// Facebook
implementation(libs.facebook.login)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 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..b82eeff9f
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationDemoActivity.kt
@@ -0,0 +1,184 @@
+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 androidx.lifecycle.lifecycleScope
+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.firebase.ui.auth.ui.screens.email.EmailAuthScreen
+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.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
+import kotlinx.coroutines.launch
+
+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,
+ enableRecoveryCodes = true,
+ ),
+ customMethodPickerLayout = { providers, onProviderSelected ->
+ MainUI(
+ authUI = authUI,
+ configuration = configuration,
+ providers = providers,
+ onProviderSelected = onProviderSelected,
+ )
+ },
+ phoneContent = { state -> PhoneSignInUI(state) },
+ mfaEnrollmentContent = { state -> MfaEnrollmentUI(state) },
+ mfaChallengeContent = { state -> MfaChallengeUI(state) },
+ reauthContent = { reauthState, onDismiss ->
+ ReauthUI(
+ state = reauthState,
+ onDismiss = onDismiss,
+ onVerified = {
+ val retry = reauthState.retryOperation
+ // Dismiss first: onDismiss resets the auth state to Idle, so
+ // running the retry afterwards keeps the Success it reports
+ // from being clobbered. Neither call suspends, so the
+ // composable's cancellation can't interleave between them —
+ // and the retry itself runs on lifecycleScope, which outlives
+ // this UI.
+ onDismiss()
+ retry?.let {
+ lifecycleScope.launch { it(applicationContext) }
+ }
+ },
+ )
+ },
+ 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()) {
+ EmailAuthScreen(
+ context = context,
+ configuration = configuration,
+ authUI = authUI,
+ onSuccess = { result ->
+ Log.d("FullCustomizationDemo", "Auth success: ${result.user?.uid}")
+ },
+ onError = { exception ->
+ Log.e("FullCustomizationDemo", "Auth failed", exception)
+ },
+ onCancel = {
+ Log.d("FullCustomizationDemo", "Auth cancelled")
+ },
+ ) { state ->
+ AuthMethodPickerUI(
+ state = state,
+ otherProviders = providers.filterNot { it is AuthProvider.Email },
+ onProviderSelected = onProviderSelected,
+ tosUrl = configuration.tosUrl,
+ ppUrl = configuration.privacyPolicyUrl,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/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..94317a9e1
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/CtaButton.kt
@@ -0,0 +1,54 @@
+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) {
+ 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..16e5ca91a
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/OtherSignInMethodsSheet.kt
@@ -0,0 +1,70 @@
+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.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun OtherSignInMethodsSheet(
+ otherProviders: List,
+ onProviderSelected: (AuthProvider) -> Unit,
+ onDismissRequest: () -> Unit,
+ tosUrl: String?,
+ ppUrl: String?,
+) {
+ ModalBottomSheet(
+ onDismissRequest = onDismissRequest,
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 64.dp),
+ ) {
+ Text(
+ text = "Other sign in methods",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 16.dp)
+ .semantics { contentDescription = "Other sign-in methods sheet title" },
+ )
+ Column(
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ otherProviders.forEach { provider ->
+ SheetProviderButton(
+ provider = provider,
+ onClick = {
+ onDismissRequest()
+ onProviderSelected(provider)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ TermsAndPrivacyForm(tosUrl = tosUrl, ppUrl = ppUrl)
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/SheetProviderButton.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/SheetProviderButton.kt
new file mode 100644
index 000000000..4b8583543
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/SheetProviderButton.kt
@@ -0,0 +1,121 @@
+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"
+ 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..fc316bde5
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthMethodPickerUI.kt
@@ -0,0 +1,106 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens
+
+import android.util.Log
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import com.firebase.ui.auth.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState
+import com.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.FirebaseAuth
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+
+private enum class FlowStep { EnterEmail, Login, SignUp }
+
+@Composable
+fun AuthMethodPickerUI(
+ state: EmailAuthContentState,
+ otherProviders: List,
+ onProviderSelected: (AuthProvider) -> Unit,
+ tosUrl: String?,
+ ppUrl: String?,
+) {
+ var flowStep by remember { mutableStateOf(FlowStep.EnterEmail) }
+ var showOtherMethods by remember { mutableStateOf(false) }
+ var isCheckingEmail by remember { mutableStateOf(false) }
+ val coroutineScope = rememberCoroutineScope()
+
+ // Password/confirmPassword are hoisted in EmailAuthContentState, not local to LoginStep/
+ // SignUpStep — they survive a round trip back to EnterEmail, so a stale password typed for
+ // one email could carry over if a different email also routes to the same step. Clear them
+ // whenever the user backs out via "Use a different email".
+ val onUseDifferentEmail: () -> Unit = {
+ state.onPasswordChange("")
+ state.onConfirmPasswordChange("")
+ flowStep = FlowStep.EnterEmail
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ when (flowStep) {
+ FlowStep.EnterEmail -> EmailEntryStep(
+ email = state.email,
+ onEmailChange = state.onEmailChange,
+ isLoading = state.isLoading || isCheckingEmail,
+ onContinue = {
+ isCheckingEmail = true
+ coroutineScope.launch {
+ val signInMethods = fetchLegacySignInMethods(state.email)
+ flowStep = if (signInMethods.isEmpty()) FlowStep.SignUp else FlowStep.Login
+ isCheckingEmail = false
+ }
+ },
+ onShowOtherMethods = { showOtherMethods = true },
+ )
+
+ FlowStep.Login -> LoginStep(
+ state = state,
+ onUseDifferentEmail = onUseDifferentEmail,
+ )
+
+ FlowStep.SignUp -> SignUpStep(
+ state = state,
+ onUseDifferentEmail = onUseDifferentEmail,
+ )
+ }
+ }
+
+ if (showOtherMethods) {
+ OtherSignInMethodsSheet(
+ otherProviders = otherProviders,
+ onProviderSelected = onProviderSelected,
+ onDismissRequest = { showOtherMethods = false },
+ tosUrl = tosUrl,
+ ppUrl = ppUrl,
+ )
+ }
+}
+
+/**
+ * Whether [email] is already registered, via Firebase Auth's `fetchSignInMethodsForEmail` —
+ * deprecated by Firebase ("legacy") and, depending on the project's Email Enumeration Protection
+ * setting, may always return an empty list regardless of whether the email exists.
+ */
+private suspend fun fetchLegacySignInMethods(email: String): List {
+ return try {
+ @Suppress("DEPRECATION")
+ FirebaseAuth.getInstance()
+ .fetchSignInMethodsForEmail(email)
+ .await()
+ .signInMethods
+ ?.filter { it.isNotBlank() }
+ ?: emptyList()
+ } catch (e: Exception) {
+ Log.w("AuthMethodPickerUI", "fetchSignInMethodsForEmail failed for $email", e)
+ emptyList()
+ }
+}
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..61fea4933
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthenticatedUI.kt
@@ -0,0 +1,279 @@
+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.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: "Set up two-factor" 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()
+
+ 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(
+ text = "Set up 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/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..65a933312
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/EmailEntryStep.kt
@@ -0,0 +1,184 @@
+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.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,
+ onContinue: () -> Unit,
+ onShowOtherMethods: () -> Unit,
+) {
+ val isEmailValid = remember(email) {
+ android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches()
+ }
+ val showEmailError = email.isNotBlank() && !isEmailValid
+
+ // verticalScroll measures content with infinite max height, and Column distributes weights
+ // against the MIN height when max is infinite (RowColumnMeasurePolicy.kt) — so
+ // heightIn(min = viewport) makes the weighted spacers expand (centering content, anchoring
+ // the link to the bottom) when everything fits, and collapse to zero (plain scrolling) when
+ // it doesn't.
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxSize()
+ .safeDrawingPadding(),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .heightIn(min = maxHeight)
+ .padding(horizontal = 48.dp, vertical = 24.dp),
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.full_customization_mascot),
+ contentDescription = "doggo - cute welcome mascot",
+ modifier = Modifier
+ .size(96.dp)
+ .offset(y = 12.dp)
+ .zIndex(1f),
+ )
+
+ Surface(
+ color = MaterialTheme.colorScheme.secondary,
+ shape = IntroShape,
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "intro - welcome headline bubble" },
+ ) {
+ Text(
+ text = "Hey there,\nWelcome",
+ style = MaterialTheme.typography.headlineMedium.copy(
+ textAlign = TextAlign.Center,
+ brush = Brush.radialGradient(
+ colors = listOf(
+ Color(0xFFFFF8F8),
+ Color(0xFFFFDDB4),
+ Color(0xFFFFD8EB),
+ ),
+ ),
+ ),
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 16.dp),
+ )
+ }
+
+ HardOffsetShadow(
+ shape = AuthFieldShape,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "email - sign in card" },
+ color = MaterialTheme.colorScheme.surface,
+ shape = AuthFieldShape,
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(
+ text = "Enter your email address to continue.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ FullCustomizationTextField(
+ value = email,
+ onValueChange = onEmailChange,
+ label = "Email address",
+ leadingIcon = { EmailFieldIcon() },
+ enabled = !isLoading,
+ isError = showEmailError,
+ supportingText = if (showEmailError) "Enter a valid email address" else null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - email address input" },
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ CtaButton(
+ text = "Continue",
+ onClick = onContinue,
+ enabled = isEmailValid && !isLoading,
+ isLoading = isLoading,
+ modifier = Modifier.semantics { contentDescription = "button - continue to password" },
+ )
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ TextButton(
+ onClick = onShowOtherMethods,
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally)
+ .semantics { contentDescription = "Other sign-in methods button" },
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Login,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Use other sign-in methods")
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/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..cbab9bff0
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/SignUpStep.kt
@@ -0,0 +1,229 @@
+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.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState
+import com.firebaseui.android.demo.R
+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("") }
+
+ val emailsMatch = confirmEmail.isNotBlank() && confirmEmail == state.email
+ val passwordsMatch = state.confirmPassword.isNotBlank() && state.confirmPassword == state.password
+ val canSignUp = firstName.isNotBlank() &&
+ lastName.isNotBlank() &&
+ emailsMatch &&
+ state.password.isNotBlank() &&
+ passwordsMatch &&
+ !state.isLoading
+
+ // verticalScroll measures content with infinite max height, and Column distributes weights
+ // against the MIN height when max is infinite (RowColumnMeasurePolicy.kt) — so
+ // heightIn(min = viewport) makes the weighted spacers expand (centering content, anchoring
+ // CTAs to the bottom) when everything fits, and collapse to zero (plain scrolling) when it
+ // doesn't.
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxSize()
+ .safeDrawingPadding(),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .heightIn(min = maxHeight)
+ .padding(horizontal = 40.dp, vertical = 24.dp),
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Image(
+ painter = painterResource(id = R.drawable.full_customization_mascot),
+ contentDescription = "doggo - cute welcome mascot",
+ modifier = Modifier.size(72.dp),
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Sign up",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+
+ HardOffsetShadow(shape = AuthFieldShape, modifier = Modifier.fillMaxWidth()) {
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "sign up card" },
+ color = MaterialTheme.colorScheme.surface,
+ shape = AuthFieldShape,
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ Row(modifier = Modifier.fillMaxWidth()) {
+ FullCustomizationTextField(
+ value = firstName,
+ onValueChange = { firstName = it },
+ label = "First name",
+ enabled = !state.isLoading,
+ shape = NameFieldStartShape,
+ modifier = Modifier
+ .weight(1f)
+ .semantics { contentDescription = "text-field - first name" },
+ )
+ FullCustomizationTextField(
+ value = lastName,
+ onValueChange = { lastName = it },
+ label = "Last name",
+ enabled = !state.isLoading,
+ shape = NameFieldEndShape,
+ modifier = Modifier
+ .weight(1f)
+ .semantics { contentDescription = "text-field - last name" },
+ )
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ FullCustomizationTextField(
+ value = state.email,
+ onValueChange = {},
+ enabled = false,
+ leadingIcon = { EmailFieldIcon() },
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - email address display" },
+ )
+ FullCustomizationTextField(
+ value = confirmEmail,
+ onValueChange = { confirmEmail = it },
+ label = "Confirm Email",
+ enabled = !state.isLoading,
+ isError = confirmEmail.isNotBlank() && !emailsMatch,
+ supportingText = if (confirmEmail.isNotBlank() && !emailsMatch) {
+ "Emails don't match"
+ } else {
+ null
+ },
+ leadingIcon = { EmailFieldIcon() },
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - confirm email" },
+ )
+ }
+
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ FullCustomizationTextField(
+ value = state.password,
+ onValueChange = state.onPasswordChange,
+ label = "Password",
+ enabled = !state.isLoading,
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - password" },
+ )
+ FullCustomizationTextField(
+ value = state.confirmPassword,
+ onValueChange = state.onConfirmPasswordChange,
+ label = "Confirm Password",
+ enabled = !state.isLoading,
+ visualTransformation = PasswordVisualTransformation(),
+ isError = state.confirmPassword.isNotBlank() && !passwordsMatch,
+ supportingText = if (state.confirmPassword.isNotBlank() && !passwordsMatch) {
+ "Passwords don't match"
+ } else {
+ null
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - confirm password" },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ CtaButton(
+ text = "Sign up",
+ onClick = {
+ state.onDisplayNameChange("$firstName $lastName".trim())
+ state.onSignUpClick()
+ },
+ enabled = canSignUp,
+ isLoading = state.isLoading,
+ modifier = Modifier.semantics { contentDescription = "button - sign up" },
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = onUseDifferentEmail,
+ enabled = !state.isLoading,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Use a different email")
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/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..59024f3bc
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/MfaEnrollmentUI.kt
@@ -0,0 +1,27 @@
+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.RecoveryCodesStep
+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 all five enrollment steps, 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)
+ MfaEnrollmentStep.ShowRecoveryCodes -> RecoveryCodesStep(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/RecoveryCodesStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/RecoveryCodesStep.kt
new file mode 100644
index 000000000..b05722aec
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/RecoveryCodesStep.kt
@@ -0,0 +1,66 @@
+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.fillMaxWidth
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+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.unit.dp
+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
+
+@Composable
+fun RecoveryCodesStep(state: MfaEnrollmentContentState) {
+ AuthPage(
+ mascot = R.drawable.full_customization_mascot,
+ mascotDescription = "doggo - cute security mascot",
+ title = "Save these codes",
+ cardContentDescription = "mfa - recovery codes card",
+ card = {
+ Text(
+ text = "Keep these somewhere safe. Each one gets you back in once if you lose " +
+ "your second factor.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
+
+ SelectionContainer {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "mfa - recovery code list" },
+ ) {
+ state.recoveryCodes.orEmpty().forEach { code ->
+ Text(
+ text = code,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+ }
+ },
+ actions = {
+ CtaButton(
+ text = "I've saved them",
+ onClick = state.onCodesSavedClick,
+ enabled = state.isValid && !state.isLoading,
+ isLoading = state.isLoading,
+ modifier = Modifier.semantics {
+ contentDescription = "button - confirm recovery codes saved"
+ },
+ )
+ },
+ )
+}
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/ReauthUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/reauth/ReauthUI.kt
new file mode 100644
index 000000000..2784597ca
--- /dev/null
+++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/reauth/ReauthUI.kt
@@ -0,0 +1,203 @@
+package com.firebaseui.android.demo.auth.fullcustomization.screens.reauth
+
+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.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.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import 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 com.firebase.ui.auth.AuthState
+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.EmailFieldIcon
+import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField
+import com.google.firebase.auth.EmailAuthProvider
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.tasks.await
+
+/**
+ * Custom UI for `FirebaseAuthScreen.reauthContent`.
+ *
+ * Unlike the MFA slots, this one is handed only the [AuthState.ReauthenticationRequired] state —
+ * no input values and no verify callback — so the re-authentication itself happens here. Providing
+ * this slot also opts out of the library's default reauth bottom sheet, which is what would
+ * otherwise drive the credential flow.
+ *
+ * @param onVerified invoked once the credential is accepted. The caller is responsible for
+ * dismissing this UI and running [AuthState.ReauthenticationRequired.retryOperation] from a scope
+ * that outlives this composable — see `FullCustomizationDemoActivity`.
+ */
+@Composable
+fun ReauthUI(
+ state: AuthState.ReauthenticationRequired,
+ onDismiss: () -> Unit,
+ onVerified: () -> Unit,
+) {
+ val email = state.user.email.orEmpty()
+ val hasPasswordProvider = remember(state.user) {
+ state.user.providerData.any { it.providerId == EmailAuthProvider.PROVIDER_ID }
+ }
+
+ if (!hasPasswordProvider) {
+ UnsupportedProviderPage(reason = state.reason, onDismiss = onDismiss)
+ return
+ }
+
+ var password by remember { mutableStateOf("") }
+ var passwordVisible by remember { mutableStateOf(false) }
+ var isVerifying by remember { mutableStateOf(false) }
+ var errorMessage by remember { mutableStateOf(null) }
+ val coroutineScope = rememberCoroutineScope()
+
+ AuthPage(
+ mascot = R.drawable.full_customization_mascot,
+ mascotDescription = "doggo - cute welcome mascot",
+ title = "Is that you?",
+ cardContentDescription = "reauth - password card",
+ card = {
+ Text(
+ text = state.reason ?: "Confirm your password to continue.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ FullCustomizationTextField(
+ value = email,
+ onValueChange = {},
+ enabled = false,
+ leadingIcon = { EmailFieldIcon() },
+ modifier = Modifier
+ .fillMaxWidth()
+ .semantics { contentDescription = "text-field - reauth email display" },
+ )
+
+ FullCustomizationTextField(
+ value = password,
+ onValueChange = {
+ password = it
+ errorMessage = null
+ },
+ label = "Password",
+ enabled = !isVerifying,
+ isError = errorMessage != null,
+ supportingText = errorMessage,
+ 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" },
+ )
+ },
+ actions = {
+ CtaButton(
+ text = "Verify",
+ onClick = {
+ coroutineScope.launch {
+ isVerifying = true
+ errorMessage = null
+ try {
+ state.user
+ .reauthenticate(
+ EmailAuthProvider.getCredential(email, password)
+ )
+ .await()
+ // Neither call suspends, so cancellation can't interleave once
+ // onVerified() tears this composable down.
+ onVerified()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ errorMessage = e.localizedMessage
+ ?: "We couldn't verify that password."
+ isVerifying = false
+ }
+ }
+ },
+ enabled = password.isNotBlank() && !isVerifying,
+ isLoading = isVerifying,
+ modifier = Modifier.semantics { contentDescription = "button - verify reauth" },
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ TextButton(
+ onClick = onDismiss,
+ enabled = !isVerifying,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text("Cancel")
+ }
+ },
+ )
+}
+
+/**
+ * The library only routes here when at least one configured provider is linked to the user, so a
+ * federated-only account still reaches this slot — it just can't be re-verified by password.
+ */
+@Composable
+private fun UnsupportedProviderPage(reason: String?, onDismiss: () -> Unit) {
+ AuthPage(
+ mascot = R.drawable.full_customization_mascot,
+ mascotDescription = "doggo - cute welcome mascot",
+ title = "Is that you?",
+ cardContentDescription = "reauth - unsupported provider card",
+ card = {
+ reason?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ Text(
+ text = "This account signs in with a provider rather than a password, so this " +
+ "demo screen can't re-verify it. Sign out and back in to continue.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.error,
+ )
+ },
+ actions = {
+ CtaButton(
+ text = "Close",
+ onClick = onDismiss,
+ modifier = Modifier.semantics { contentDescription = "button - dismiss reauth" },
+ )
+ },
+ )
+}
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/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt
index 65ea606dd..1027b9cab 100644
--- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt
@@ -1,7 +1,6 @@
package com.firebase.ui.auth.configuration.auth_provider
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.AuthState
@@ -14,27 +13,32 @@ import kotlinx.coroutines.tasks.await
/**
* Creates a remembered launcher function for anonymous sign-in.
*
+ * @param config Authentication UI configuration
+ * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure
* @return A launcher function that starts the anonymous sign-in flow when invoked
*
* @see signInAnonymously
* @see createOrLinkUserWithEmailAndPassword for upgrading anonymous accounts
*/
@Composable
-internal fun FirebaseAuthUI.rememberAnonymousSignInHandler(config: AuthUIConfiguration): () -> Unit {
+internal fun FirebaseAuthUI.rememberAnonymousSignInHandler(
+ config: AuthUIConfiguration,
+ onSignInFailure: (AuthException) -> Unit = {},
+): () -> Unit {
val context = androidx.compose.ui.platform.LocalContext.current
val coroutineScope = rememberCoroutineScope()
- return remember(this) {
- {
- coroutineScope.launch {
- try {
- signInAnonymously(config)
- } catch (e: AuthException) {
- // Already an AuthException, don't re-wrap it
- updateAuthState(AuthState.Error(e))
- } catch (e: Exception) {
- val authException = AuthException.from(e, context)
- updateAuthState(AuthState.Error(authException))
- }
+ return {
+ coroutineScope.launch {
+ try {
+ signInAnonymously(config)
+ } catch (e: AuthException) {
+ // Already an AuthException, don't re-wrap it
+ updateAuthState(AuthState.Error(e))
+ if (e !is AuthException.AuthCancelledException) onSignInFailure(e)
+ } catch (e: Exception) {
+ val authException = AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException)
}
}
}
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt
index d6a9622e7..6cefb834a 100644
--- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt
@@ -19,8 +19,10 @@ import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import com.facebook.AccessToken
import com.facebook.CallbackManager
import com.facebook.FacebookCallback
@@ -47,6 +49,7 @@ import kotlinx.coroutines.launch
* @param config The [AuthUIConfiguration] containing authentication settings
* @param provider The [AuthProvider.Facebook] configuration with scopes and credential provider
* @param loginManagerProvider Provides logout operations to clear stale Facebook sessions
+ * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure
*
* @return A launcher function that starts the Facebook sign-in flow when invoked
*
@@ -58,10 +61,15 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher(
config: AuthUIConfiguration,
provider: AuthProvider.Facebook,
loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(),
+ onSignInFailure: (AuthException) -> Unit = {},
): () -> Unit {
val coroutineScope = rememberCoroutineScope()
val callbackManager = remember { CallbackManager.Factory.create() }
val loginManager = LoginManager.getInstance()
+ val currentContext by rememberUpdatedState(context)
+ val currentConfig by rememberUpdatedState(config)
+ val currentProvider by rememberUpdatedState(provider)
+ val currentOnSignInFailure by rememberUpdatedState(onSignInFailure)
val launcher = rememberLauncherForActivityResult(
loginManager.createLogInActivityResultContract(
@@ -71,7 +79,7 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher(
onResult = {},
)
- DisposableEffect(config) {
+ DisposableEffect(Unit) {
loginManager.registerCallback(
callbackManager,
object : FacebookCallback {
@@ -79,17 +87,19 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher(
coroutineScope.launch {
try {
signInWithFacebook(
- context = context,
- config = config,
- provider = provider,
+ context = currentContext,
+ config = currentConfig,
+ provider = currentProvider,
accessToken = result.accessToken,
)
} catch (e: AuthException) {
// Already an AuthException, don't re-wrap it
updateAuthState(AuthState.Error(e))
+ if (e !is AuthException.AuthCancelledException) currentOnSignInFailure(e)
} catch (e: Exception) {
- val authException = AuthException.from(e, context)
+ val authException = AuthException.from(e, currentContext)
updateAuthState(AuthState.Error(authException))
+ if (authException !is AuthException.AuthCancelledException) currentOnSignInFailure(authException)
}
}
}
@@ -100,12 +110,13 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher(
override fun onError(error: FacebookException) {
Log.e("FacebookAuthProvider", "Error during Facebook sign in", error)
- val authException = AuthException.from(error, context)
+ val authException = AuthException.from(error, currentContext)
updateAuthState(
AuthState.Error(
authException
)
)
+ currentOnSignInFailure(authException)
}
})
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt
index 139eab163..b46f184fb 100644
--- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt
@@ -3,7 +3,6 @@ package com.firebase.ui.auth.configuration.auth_provider
import android.content.Context
import android.util.Log
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.credentials.CredentialManager
import androidx.credentials.exceptions.GetCredentialCancellationException
@@ -48,6 +47,7 @@ import kotlinx.coroutines.launch
* @param context Android context for Credential Manager
* @param config Authentication UI configuration
* @param provider Google provider configuration with server client ID and optional scopes
+ * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure
* @return A callback function that initiates Google Sign-In when invoked
*
* @see signInWithGoogle
@@ -58,19 +58,20 @@ internal fun FirebaseAuthUI.rememberGoogleSignInHandler(
context: Context,
config: AuthUIConfiguration,
provider: AuthProvider.Google,
+ onSignInFailure: (AuthException) -> Unit = {},
): () -> Unit {
val coroutineScope = rememberCoroutineScope()
- return remember(this, config) {
- {
- coroutineScope.launch {
- try {
- signInWithGoogle(context, config, provider)
- } catch (e: AuthException) {
- updateAuthState(AuthState.Error(e))
- } catch (e: Exception) {
- val authException = AuthException.from(e, context)
- updateAuthState(AuthState.Error(authException))
- }
+ return {
+ coroutineScope.launch {
+ try {
+ signInWithGoogle(context, config, provider)
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ if (e !is AuthException.AuthCancelledException) onSignInFailure(e)
+ } catch (e: Exception) {
+ val authException = AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException)
}
}
}
@@ -161,6 +162,21 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle(
autoSelectEnabled = provider.autoSelectEnabled
)
} catch (fallbackException: NoCredentialException) {
+ // Credential Manager doesn't distinguish "no account on device" from
+ // developer-side misconfiguration, so log the possible causes for
+ // debugging. Never surfaced to end users: the overwhelming majority
+ // hitting this genuinely have no account, and Firebase Console
+ // guidance would just confuse them.
+ Log.w(
+ "GoogleAuthProvider",
+ "No credential returned from Credential Manager after trying both " +
+ "authorized and all accounts. Possible causes: (1) no Google " +
+ "account on this device, (2) no Android OAuth client / SHA-1 " +
+ "registered for this app's package + signing certificate in the " +
+ "Firebase console, or (3) the Credential Manager Google ID " +
+ "provider is unavailable on this device.",
+ fallbackException
+ )
// No Google accounts available on device at all
throw AuthException.UnknownException(
message = "No Google accounts available.\n\nPlease add a Google account to your device and try again.",
diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt
index add6bb235..58d35cea0 100644
--- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt
@@ -3,7 +3,6 @@ package com.firebase.ui.auth.configuration.auth_provider
import android.app.Activity
import android.content.Context
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.AuthState
@@ -41,6 +40,7 @@ import kotlinx.coroutines.tasks.await
*
* @param config Authentication UI configuration
* @param provider OAuth provider configuration
+ * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure
*
* @return Lambda that triggers OAuth sign-in when invoked
*
@@ -54,6 +54,7 @@ internal fun FirebaseAuthUI.rememberOAuthSignInHandler(
activity: Activity?,
config: AuthUIConfiguration,
provider: AuthProvider.OAuth,
+ onSignInFailure: (AuthException) -> Unit = {},
): () -> Unit {
val coroutineScope = rememberCoroutineScope()
activity ?: throw IllegalStateException(
@@ -61,22 +62,22 @@ internal fun FirebaseAuthUI.rememberOAuthSignInHandler(
"Ensure FirebaseAuthScreen is used within an Activity."
)
- return remember(this, provider.providerId, config) {
- {
- coroutineScope.launch {
- try {
- signInWithProvider(
- context = context,
- config = config,
- activity = activity,
- provider = provider
- )
- } catch (e: AuthException) {
- updateAuthState(AuthState.Error(e))
- } catch (e: Exception) {
- val authException = AuthException.from(e, context)
- updateAuthState(AuthState.Error(authException))
- }
+ return {
+ coroutineScope.launch {
+ try {
+ signInWithProvider(
+ context = context,
+ config = config,
+ activity = activity,
+ provider = provider
+ )
+ } catch (e: AuthException) {
+ updateAuthState(AuthState.Error(e))
+ if (e !is AuthException.AuthCancelledException) onSignInFailure(e)
+ } catch (e: Exception) {
+ val authException = AuthException.from(e, context)
+ updateAuthState(AuthState.Error(authException))
+ if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException)
}
}
}
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 852fd10cd..6b8b6c51d 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
@@ -190,6 +190,7 @@ fun FirebaseAuthScreen(
)
)
},
+ onSignInFailure = onSignInFailure,
)
val continueWithProvider: (String) -> Unit = { providerId ->
configuration.providers.find { it.providerId == providerId }?.let { onProviderSelected(it) }
@@ -222,6 +223,8 @@ fun FirebaseAuthScreen(
) {
composable(AuthRoute.MethodPicker.route) {
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)
}
@@ -1033,6 +1036,7 @@ private fun FirebaseAuthUI.rememberOnProviderSelected(
config: AuthUIConfiguration,
onNavigate: (AuthRoute) -> Unit,
onUnknownProvider: ((AuthProvider) -> Unit)? = null,
+ onSignInFailure: (AuthException) -> Unit = {},
): (AuthProvider) -> Unit {
val anonymousProvider = config.providers.filterIsInstance().firstOrNull()
val googleProvider = config.providers.filterIsInstance().firstOrNull()
@@ -1044,16 +1048,18 @@ private fun FirebaseAuthUI.rememberOnProviderSelected(
val twitterProvider = config.providers.filterIsInstance().firstOrNull()
val genericOAuthProviders = config.providers.filterIsInstance()
- val onSignInAnonymously = anonymousProvider?.let { rememberAnonymousSignInHandler(config) }
- val onSignInWithGoogle = googleProvider?.let { rememberGoogleSignInHandler(context, config, it) }
- val onSignInWithFacebook = facebookProvider?.let { rememberSignInWithFacebookLauncher(context, config, it) }
- val onSignInWithApple = appleProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) }
- val onSignInWithGithub = githubProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) }
- val onSignInWithMicrosoft = microsoftProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) }
- val onSignInWithYahoo = yahooProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) }
- val onSignInWithTwitter = twitterProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) }
+ val onSignInAnonymously = anonymousProvider?.let { rememberAnonymousSignInHandler(config, onSignInFailure) }
+ val onSignInWithGoogle = googleProvider?.let { rememberGoogleSignInHandler(context, config, it, onSignInFailure) }
+ val onSignInWithFacebook = facebookProvider?.let {
+ rememberSignInWithFacebookLauncher(context, config, it, onSignInFailure = onSignInFailure)
+ }
+ val onSignInWithApple = appleProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) }
+ val onSignInWithGithub = githubProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) }
+ val onSignInWithMicrosoft = microsoftProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) }
+ val onSignInWithYahoo = yahooProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) }
+ val onSignInWithTwitter = twitterProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) }
val genericOAuthHandlers = genericOAuthProviders.associateWith {
- rememberOAuthSignInHandler(context, activity, config, it)
+ rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure)
}
return { provider ->
diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt
index 6e71c4170..18f396f9b 100644
--- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt
@@ -15,6 +15,7 @@
package com.firebase.ui.auth.configuration.auth_provider
import android.content.Context
+import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.core.app.ApplicationProvider
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.AuthState
@@ -38,6 +39,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
+import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers
@@ -54,6 +56,9 @@ import org.robolectric.annotation.Config
@Config(manifest = Config.NONE)
class AnonymousAuthProviderFirebaseAuthUITest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
@Mock
private lateinit var mockFirebaseAuth: FirebaseAuth
@@ -214,6 +219,35 @@ class AnonymousAuthProviderFirebaseAuthUITest {
assertThat(errorState.exception).isInstanceOf(AuthException.UnknownException::class.java)
}
+ // =============================================================================================
+ // rememberAnonymousSignInHandler - onSignInFailure reporting
+ // =============================================================================================
+
+ @Test
+ fun `rememberAnonymousSignInHandler reports failure via onSignInFailure immediately, at the source`() {
+ val networkException = FirebaseNetworkException("Network error")
+ val taskCompletionSource = TaskCompletionSource()
+ taskCompletionSource.setException(networkException)
+ `when`(mockFirebaseAuth.signInAnonymously()).thenReturn(taskCompletionSource.task)
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val reportedFailures = mutableListOf()
+ var launcher: (() -> Unit)? = null
+
+ composeTestRule.setContent {
+ launcher = instance.rememberAnonymousSignInHandler(
+ config = config,
+ onSignInFailure = { reportedFailures.add(it) },
+ )
+ }
+
+ composeTestRule.runOnIdle { launcher?.invoke() }
+ composeTestRule.waitForIdle()
+
+ assertThat(reportedFailures).hasSize(1)
+ assertThat(reportedFailures.single()).isInstanceOf(AuthException.NetworkException::class.java)
+ }
+
// =============================================================================================
// Anonymous Account Upgrade Tests
// =============================================================================================
diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt
index 312de80cf..bba4f8c50 100644
--- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt
@@ -15,9 +15,12 @@
package com.firebase.ui.auth.configuration.auth_provider
import android.content.Context
+import android.util.Log
+import androidx.compose.ui.test.junit4.createComposeRule
import androidx.core.net.toUri
import androidx.credentials.CredentialManager
import androidx.credentials.exceptions.GetCredentialCancellationException
+import androidx.credentials.exceptions.NoCredentialException
import androidx.test.core.app.ApplicationProvider
import com.firebase.ui.auth.AuthException
import com.firebase.ui.auth.AuthState
@@ -38,6 +41,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
+import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
@@ -53,6 +57,7 @@ import org.mockito.kotlin.eq
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowLog
/**
* Comprehensive unit tests for Google Sign-In provider methods in FirebaseAuthUI.
@@ -70,6 +75,9 @@ import org.robolectric.annotation.Config
@Config(manifest = Config.NONE)
class GoogleAuthProviderFirebaseAuthUITest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
@Mock
private lateinit var mockFirebaseAuth: FirebaseAuth
@@ -440,6 +448,65 @@ class GoogleAuthProviderFirebaseAuthUITest {
assertThat(errorState.exception).isInstanceOf(AuthException.UnknownException::class.java)
}
+ @Test
+ fun `Sign in with Google when both credential attempts throw NoCredentialException logs diagnostic warning`() = runTest {
+ ShadowLog.clear()
+ val noCredentialException = NoCredentialException("No credential available")
+
+ `when`(
+ mockCredentialManagerProvider.getGoogleCredential(
+ context = eq(applicationContext),
+ credentialManager = any(),
+ serverClientId = eq("test-client-id"),
+ filterByAuthorizedAccounts = eq(true),
+ autoSelectEnabled = eq(false)
+ )
+ ).thenAnswer { throw noCredentialException }
+
+ `when`(
+ mockCredentialManagerProvider.getGoogleCredential(
+ context = eq(applicationContext),
+ credentialManager = any(),
+ serverClientId = eq("test-client-id"),
+ filterByAuthorizedAccounts = eq(false),
+ autoSelectEnabled = eq(false)
+ )
+ ).thenAnswer { throw noCredentialException }
+
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val googleProvider = AuthProvider.Google(
+ serverClientId = "test-client-id",
+ scopes = emptyList()
+ )
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(googleProvider)
+ }
+ }
+
+ try {
+ instance.signInWithGoogle(
+ context = applicationContext,
+ config = config,
+ provider = googleProvider,
+ authorizationProvider = mockAuthorizationProvider,
+ credentialManagerProvider = mockCredentialManagerProvider
+ )
+ throw AssertionError("Expected exception to be thrown")
+ } catch (e: AuthException) {
+ // User-facing message stays generic - never mentions Firebase Console/SHA-1
+ assertThat(e).isInstanceOf(AuthException.UnknownException::class.java)
+ assertThat(e.message).contains("No Google accounts available")
+ }
+
+ // Diagnostic detail goes to Logcat only, for developers
+ val diagnosticLog = ShadowLog.getLogs().firstOrNull {
+ it.type == Log.WARN && it.tag == "GoogleAuthProvider" && it.msg.contains("SHA-1")
+ }
+ assertThat(diagnosticLog).isNotNull()
+ }
+
@Test
fun `Sign in with Google when Firebase sign-in fails should throw AuthException`() = runTest {
val mockCredential = mock(AuthCredential::class.java)
@@ -963,4 +1030,59 @@ class GoogleAuthProviderFirebaseAuthUITest {
val finalState = instance.authStateFlow().first { it !is AuthState.Loading }
assertThat(finalState).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false))
}
+
+ // =============================================================================================
+ // rememberGoogleSignInHandler - onSignInFailure reporting
+ // =============================================================================================
+
+ @Test
+ fun `rememberGoogleSignInHandler reports failure via onSignInFailure immediately, at the source`() {
+ val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
+ val googleProvider = AuthProvider.Google(
+ serverClientId = "test-client-id",
+ scopes = emptyList()
+ )
+ val config = authUIConfiguration {
+ context = applicationContext
+ providers {
+ provider(googleProvider)
+ }
+ }
+
+ // A picker-level failure that used to never reach onSignInFailure at all:
+ // the outer fallback throws AuthException.UnknownException when no Google accounts are found.
+ instance.testCredentialManagerProvider = object : AuthProvider.Google.CredentialManagerProvider {
+ override suspend fun getGoogleCredential(
+ context: Context,
+ credentialManager: CredentialManager,
+ serverClientId: String,
+ filterByAuthorizedAccounts: Boolean,
+ autoSelectEnabled: Boolean
+ ): AuthProvider.Google.GoogleSignInResult {
+ throw AuthException.UnknownException(
+ "No Google accounts available.\n\nPlease add a Google account to your device and try again."
+ )
+ }
+
+ override suspend fun clearCredentialState(context: Context, credentialManager: CredentialManager) = Unit
+ }
+
+ val reportedFailures = mutableListOf()
+ var launcher: (() -> Unit)? = null
+
+ composeTestRule.setContent {
+ launcher = instance.rememberGoogleSignInHandler(
+ context = applicationContext,
+ config = config,
+ provider = googleProvider,
+ onSignInFailure = { reportedFailures.add(it) },
+ )
+ }
+
+ composeTestRule.runOnIdle { launcher?.invoke() }
+ composeTestRule.waitForIdle()
+
+ assertThat(reportedFailures).hasSize(1)
+ assertThat(reportedFailures.single()).isInstanceOf(AuthException.UnknownException::class.java)
+ }
}
diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/testutil/EmulatorApi.kt b/e2eTest/src/test/java/com/firebase/ui/auth/testutil/EmulatorApi.kt
index 4430c37a7..6a2c60e8a 100644
--- a/e2eTest/src/test/java/com/firebase/ui/auth/testutil/EmulatorApi.kt
+++ b/e2eTest/src/test/java/com/firebase/ui/auth/testutil/EmulatorApi.kt
@@ -18,23 +18,48 @@ class EmulatorAuthApi(
* This function calls the emulator's clear data endpoint to remove all accounts,
* OOB codes, and other authentication data. This ensures test isolation by providing
* a clean slate for each test.
+ *
+ * Retries on transient failures (e.g. a loaded CI runner momentarily failing to respond)
+ * and throws if the emulator still can't be cleared, so a broken reset fails the test
+ * loudly instead of silently leaking stale accounts into the next test.
*/
fun clearEmulatorData() {
- try {
- clearAccounts()
- } catch (e: Exception) {
- println("WARNING: Exception while clearing emulator data: ${e.message}")
+ val maxRetries = 3
+ var lastError: Exception? = null
+ for (attempt in 1..maxRetries) {
+ try {
+ clearAccounts()
+ return
+ } catch (e: InterruptedException) {
+ Thread.currentThread().interrupt()
+ throw e
+ } catch (e: Exception) {
+ lastError = e
+ println("WARNING: Failed to clear emulator data (attempt $attempt/$maxRetries): ${e.message}")
+ if (attempt < maxRetries) {
+ try {
+ Thread.sleep(500L * attempt)
+ } catch (ie: InterruptedException) {
+ Thread.currentThread().interrupt()
+ throw ie
+ }
+ }
+ }
}
+ throw IllegalStateException(
+ "Failed to clear Firebase Auth Emulator data after $maxRetries attempts. " +
+ "Aborting test to avoid running against stale emulator state.",
+ lastError
+ )
}
fun clearAccounts() {
httpClient.delete("/emulator/v1/projects/$projectId/accounts") { connection ->
val responseCode = connection.responseCode
if (responseCode !in 200..299) {
- println("WARNING: Failed to clear emulator data: HTTP $responseCode")
- } else {
- println("TEST: Cleared emulator data")
+ throw IllegalStateException("Failed to clear emulator data: HTTP $responseCode")
}
+ println("TEST: Cleared emulator data")
}
}
diff --git a/storage/build.gradle.kts b/storage/build.gradle.kts
index 7ac13aeba..d3cfc2d40 100644
--- a/storage/build.gradle.kts
+++ b/storage/build.gradle.kts
@@ -57,4 +57,4 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockito.core)
-}
\ No newline at end of file
+}