sample(auth): full customization demo#2414
Conversation
…-up flow and Figma-accurate styling
There was a problem hiding this comment.
Code Review
This pull request introduces a fully customized authentication flow demo (FullCustomizationDemoActivity and associated steps) to showcase the capabilities of customMethodPickerLayout in FirebaseAuthScreen, alongside minor inset adjustments in the default auth screens. The review feedback is highly constructive and should be addressed: it recommends implementing a BackHandler for proper step-back navigation, making the alternative sign-in methods sheet scrollable to prevent clipping on smaller screens, passing the FirebaseAuth instance dynamically to support custom configurations, performing case-insensitive and trimmed email validation, and ensuring the loading indicator in CtaButton has sufficient contrast.
| 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 | ||
| } |
There was a problem hiding this comment.
When the user is on the Login or SignUp step, pressing the system back button will exit the entire activity/screen instead of returning to the EnterEmail step. Adding a BackHandler when flowStep != FlowStep.EnterEmail intercepts the back press and routes the user back to the email entry step, matching standard Android navigation expectations.
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
}
androidx.activity.compose.BackHandler(enabled = flowStep != FlowStep.EnterEmail) {
onUseDifferentEmail()
}| Column( | ||
| modifier = Modifier | ||
| .fillMaxWidth() | ||
| .padding(horizontal = 64.dp), | ||
| ) { |
There was a problem hiding this comment.
With up to 9 alternative identity providers configured, the list of buttons and the Terms of Service footer will easily exceed the available screen height on smaller devices or in landscape orientation. Since the sheet's Column is not scrollable, this will cause content to be clipped and become inaccessible. Wrapping the column in a scrollable modifier (e.g., .verticalScroll(rememberScrollState())) ensures all options remain accessible.
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(androidx.compose.foundation.rememberScrollState())
.padding(horizontal = 64.dp),
) {| fun SingleFieldSignInUI( | ||
| state: EmailAuthContentState, | ||
| otherProviders: List<AuthProvider>, | ||
| onProviderSelected: (AuthProvider) -> Unit, | ||
| tosUrl: String?, | ||
| ppUrl: String?, | ||
| ) { |
There was a problem hiding this comment.
To support multi-app configurations or custom Firebase Auth instances (such as when using the emulator), avoid calling FirebaseAuth.getInstance() directly. Instead, pass the configured FirebaseAuth instance from the parent screen.
fun SingleFieldSignInUI(
state: EmailAuthContentState,
auth: FirebaseAuth,
otherProviders: List<AuthProvider>,
onProviderSelected: (AuthProvider) -> Unit,
tosUrl: String?,
ppUrl: String?,
)| onContinue = { | ||
| isCheckingEmail = true | ||
| coroutineScope.launch { | ||
| val signInMethods = fetchLegacySignInMethods(state.email) | ||
| flowStep = if (signInMethods.isEmpty()) FlowStep.SignUp else FlowStep.Login | ||
| isCheckingEmail = false | ||
| } | ||
| }, |
There was a problem hiding this comment.
Pass the auth instance to fetchLegacySignInMethods.
| onContinue = { | |
| isCheckingEmail = true | |
| coroutineScope.launch { | |
| val signInMethods = fetchLegacySignInMethods(state.email) | |
| flowStep = if (signInMethods.isEmpty()) FlowStep.SignUp else FlowStep.Login | |
| isCheckingEmail = false | |
| } | |
| }, | |
| onContinue = { | |
| isCheckingEmail = true | |
| coroutineScope.launch { | |
| val signInMethods = fetchLegacySignInMethods(auth, state.email) | |
| flowStep = if (signInMethods.isEmpty()) FlowStep.SignUp else FlowStep.Login | |
| isCheckingEmail = false | |
| } | |
| }, |
| private suspend fun fetchLegacySignInMethods(email: String): List<String> { | ||
| return try { | ||
| @Suppress("DEPRECATION") | ||
| FirebaseAuth.getInstance() | ||
| .fetchSignInMethodsForEmail(email) | ||
| .await() | ||
| .signInMethods | ||
| ?.filter { it.isNotBlank() } | ||
| ?: emptyList() | ||
| } catch (e: Exception) { | ||
| Log.w("SingleFieldSignInUI", "fetchSignInMethodsForEmail failed for $email", e) | ||
| emptyList() | ||
| } | ||
| } |
There was a problem hiding this comment.
Update fetchLegacySignInMethods to accept and use the passed FirebaseAuth instance instead of retrieving the default global instance.
private suspend fun fetchLegacySignInMethods(auth: FirebaseAuth, email: String): List<String> {
return try {
@Suppress("DEPRECATION")
auth.fetchSignInMethodsForEmail(email)
.await()
.signInMethods
?.filter { it.isNotBlank() }
?: emptyList()
} catch (e: Exception) {
Log.w("SingleFieldSignInUI", "fetchSignInMethodsForEmail failed for $email", e)
emptyList()
}
}| ) { state -> | ||
| SingleFieldSignInUI( | ||
| state = state, | ||
| otherProviders = providers.filterNot { it is AuthProvider.Email }, | ||
| onProviderSelected = onProviderSelected, | ||
| tosUrl = configuration.tosUrl, | ||
| ppUrl = configuration.privacyPolicyUrl, | ||
| ) | ||
| } |
There was a problem hiding this comment.
Pass authUI.auth to SingleFieldSignInUI to match the updated signature.
) { state ->
SingleFieldSignInUI(
state = state,
auth = authUI.auth,
otherProviders = providers.filterNot { it is AuthProvider.Email },
onProviderSelected = onProviderSelected,
tosUrl = configuration.tosUrl,
ppUrl = configuration.privacyPolicyUrl,
)
}| var lastName by remember { mutableStateOf("") } | ||
| var confirmEmail by remember { mutableStateOf("") } | ||
|
|
||
| val emailsMatch = confirmEmail.isNotBlank() && confirmEmail == state.email |
There was a problem hiding this comment.
Email addresses should be compared case-insensitively and ignoring leading/trailing whitespace, as mobile keyboards often auto-capitalize the first letter or append spaces. Using equals(..., ignoreCase = true) and trim() prevents validation failures due to casing or accidental spacing differences.
| val emailsMatch = confirmEmail.isNotBlank() && confirmEmail == state.email | |
| val emailsMatch = confirmEmail.isNotBlank() && confirmEmail.trim().equals(state.email.trim(), ignoreCase = true) |
| if (isLoading) { | ||
| CircularProgressIndicator(modifier = Modifier.size(20.dp)) | ||
| } else { |
There was a problem hiding this comment.
By default, CircularProgressIndicator uses the theme's primary color. Since CtaButton also uses the primary color as its default background, the progress indicator will have extremely low contrast or be completely invisible. Setting its color to LocalContentColor.current ensures it matches the button's text color (e.g., onPrimary) and remains clearly visible.
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = androidx.compose.material3.LocalContentColor.current
)
} else {
Preview
full-customization-flow.webm
other-sign-in-methods.webm