From 0afcb2473a01b0724f4fff2475e55a6c643a3172 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Fri, 31 Oct 2025 13:49:41 +0100 Subject: [PATCH 1/7] fix: email link sign in support --- .../auth_provider/AuthProvider.kt | 6 -- .../configuration/theme/AuthUITheme.kt | 3 +- .../ui/components/AuthProviderButton.kt | 4 -- .../ui/screens/email/EmailAuthScreen.kt | 65 +++++++++++-------- .../auth/compose/ui/screens/email/SignInUI.kt | 64 ++++++++++++++++-- .../composeapp/HighLevelApiDemoActivity.kt | 18 ----- .../compose/ui/screens/EmailAuthScreenTest.kt | 1 + 7 files changed, 99 insertions(+), 62 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt index ffc697698..097cf9c0a 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProvider.kt @@ -154,12 +154,6 @@ abstract class AuthProvider(open val providerId: String, open val providerName: * A list of custom password validation rules. */ val passwordValidationRules: List, - - /** - * Optional custom button label to differentiate between multiple email providers. - * If null, uses the default string from stringProvider. Defaults to null. - */ - val buttonLabel: String? = null, ) : AuthProvider(providerId = Provider.EMAIL.id, providerName = Provider.EMAIL.providerName) { companion object { const val SESSION_ID_LENGTH = 10 diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUITheme.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUITheme.kt index 122da349e..4ef809e9c 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUITheme.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/AuthUITheme.kt @@ -144,7 +144,8 @@ class AuthUITheme( val topAppBarColors get() = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.primary, - titleContentColor = MaterialTheme.colorScheme.onPrimary + titleContentColor = MaterialTheme.colorScheme.onPrimary, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, ) } } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt index 5186860b2..b0ad943db 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt @@ -153,10 +153,6 @@ internal fun resolveProviderLabel( context: android.content.Context ): String = when (provider) { is AuthProvider.GenericOAuth -> provider.buttonLabel - is AuthProvider.Email -> { - // Use custom button label if provided, otherwise use default - provider.buttonLabel ?: stringProvider.signInWithEmail - } is AuthProvider.Apple -> { // Use Apple-specific locale if provided, otherwise use default stringProvider if (provider.locale != null) { diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt index 7f8ce7293..9abd3a8b8 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt @@ -95,6 +95,7 @@ class EmailAuthContentState( val displayName: String, val onDisplayNameChange: (String) -> Unit, val onSignInClick: () -> Unit, + val onSignInEmailLinkClick: () -> Unit, val onSignUpClick: () -> Unit, val onSendResetLinkClick: () -> Unit, val resetLinkSent: Boolean = false, @@ -166,32 +167,26 @@ fun EmailAuthScreen( is AuthState.Error -> { val exception = AuthException.from(state.exception) onError(exception) - - // Show dialog for screen-specific errors using top-level controller - // Navigation-related errors are handled by FirebaseAuthScreen - if (exception !is AuthException.AccountLinkingRequiredException && - exception !is AuthException.EmailLinkPromptForEmailException && - exception !is AuthException.EmailLinkCrossDeviceLinkingException - ) { - dialogController?.showErrorDialog( - exception = exception, - onRetry = { ex -> - when (ex) { - is AuthException.InvalidCredentialsException -> { - // User can retry sign in with corrected credentials - } - is AuthException.EmailAlreadyInUseException -> { - // Switch to sign-in mode - mode.value = EmailAuthMode.SignIn - } - else -> Unit + dialogController?.showErrorDialog( + exception = exception, + onRetry = { ex -> + when (ex) { + is AuthException.InvalidCredentialsException -> { + // User can retry sign in with corrected credentials } - }, - onDismiss = { - // Dialog dismissed + + is AuthException.EmailAlreadyInUseException -> { + // Switch to sign-in mode + mode.value = EmailAuthMode.SignIn + } + + else -> Unit } - ) - } + }, + onDismiss = { + // Dialog dismissed + } + ) } is AuthState.Cancelled -> { @@ -263,6 +258,23 @@ fun EmailAuthScreen( } } }, + onSignInEmailLinkClick = { + coroutineScope.launch { + try { + if (provider.isEmailLinkSignInEnabled) { + authUI.sendSignInLinkToEmail( + context = context, + config = configuration, + provider = provider, + email = emailTextValue.value, + credentialForLinking = authCredentialForLinking, + ) + } + } catch (e: Exception) { + onError(AuthException.from(e)) + } + } + }, onSignUpClick = { coroutineScope.launch { try { @@ -302,7 +314,7 @@ fun EmailAuthScreen( onGoToResetPassword = { textValues.forEach { it.value = "" } mode.value = EmailAuthMode.ResetPassword - } + }, ) if (content != null) { @@ -320,7 +332,7 @@ fun EmailAuthScreen( private fun DefaultEmailAuthContent( configuration: AuthUIConfiguration, state: EmailAuthContentState, - onCancel: () -> Unit + onCancel: () -> Unit, ) { when (state.mode) { EmailAuthMode.SignIn -> { @@ -333,6 +345,7 @@ private fun DefaultEmailAuthContent( onEmailChange = state.onEmailChange, onPasswordChange = state.onPasswordChange, onSignInClick = state.onSignInClick, + onSignInWithEmailLink = state.onSignInEmailLinkClick, onGoToSignUp = state.onGoToSignUp, onGoToResetPassword = state.onGoToResetPassword, onNavigateBack = onCancel diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInUI.kt index fb3cf4ca7..9c256617b 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInUI.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.Column 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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding @@ -31,6 +32,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -75,22 +77,27 @@ fun SignInUI( onEmailChange: (String) -> Unit, onPasswordChange: (String) -> Unit, onSignInClick: () -> Unit, + onSignInWithEmailLink: () -> Unit, onGoToSignUp: () -> Unit, onGoToResetPassword: () -> Unit, onNavigateBack: (() -> Unit)? = null, ) { val provider = configuration.providers.filterIsInstance().first() val stringProvider = LocalAuthUIStringProvider.current + + // Local state to track if user chose email link sign-in + val isEmailLinkMode = remember { mutableStateOf(false) } + val emailValidator = remember { EmailValidator(stringProvider) } - val passwordValidator = remember { + val passwordValidator = remember(isEmailLinkMode.value) { PasswordValidator(stringProvider = stringProvider, rules = emptyList()) } - val isFormValid = remember(email, password) { + val isFormValid = remember(email, password, isEmailLinkMode.value) { derivedStateOf { listOf( emailValidator.validate(email), - if (!provider.isEmailLinkSignInEnabled) + if (!isEmailLinkMode.value) passwordValidator.validate(password) else true, ).all { it } } @@ -176,7 +183,7 @@ fun SignInUI( } ) Spacer(modifier = Modifier.height(16.dp)) - if (!provider.isEmailLinkSignInEnabled) { + if (!isEmailLinkMode.value) { AuthTextField( value = password, validator = passwordValidator, @@ -191,7 +198,7 @@ fun SignInUI( ) Spacer(modifier = Modifier.height(8.dp)) } - if (!provider.isEmailLinkSignInEnabled) { + if (!isEmailLinkMode.value) { TextButton( modifier = Modifier .align(Alignment.Start), @@ -216,7 +223,7 @@ fun SignInUI( .align(Alignment.End), ) { // Signup is hidden for email link sign in - if (!provider.isEmailLinkSignInEnabled) { + if (!isEmailLinkMode.value) { Button( onClick = { onGoToSignUp() @@ -232,7 +239,11 @@ fun SignInUI( // TODO(demolaf): When signIn is fired if Exception is UserNotFound // then we check if provider.isNewAccountsAllowed then we show signUp // else we show an error dialog stating signup is not allowed - onSignInClick() + if (!isEmailLinkMode.value) { + onSignInClick() + } else { + onSignInWithEmailLink() + } }, enabled = !isLoading && isFormValid.value, ) { @@ -246,6 +257,44 @@ fun SignInUI( } } } + + // Show toggle between password and email link sign-in + if (provider.isEmailLinkSignInEnabled) { + Spacer(modifier = Modifier.height(64.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + HorizontalDivider(modifier = Modifier.weight(1f)) + Text( + text = "or Continue with", + modifier = Modifier.padding(horizontal = 8.dp), + style = MaterialTheme.typography.bodySmall + ) + HorizontalDivider(modifier = Modifier.weight(1f)) + } + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = { + isEmailLinkMode.value = !isEmailLinkMode.value + if (isEmailLinkMode.value) { + // Switching to email link mode: clear password + onPasswordChange("") + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) { + Text( + if (isEmailLinkMode.value) { + "Sign in with password".uppercase() + } else { + "Sign in with email link".uppercase() + } + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) TermsAndPrivacyForm( modifier = Modifier.align(Alignment.End), @@ -285,6 +334,7 @@ fun PreviewSignInUI() { onEmailChange = { email -> }, onPasswordChange = { password -> }, onSignInClick = {}, + onSignInWithEmailLink = {}, onGoToSignUp = {}, onGoToResetPassword = {}, ) diff --git a/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt index b7204a02d..3ec0d3a1e 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt @@ -56,23 +56,6 @@ class HighLevelApiDemoActivity : ComponentActivity() { serverClientId = "771411398215-o39fujhds88bs4mb5ai7u6o73g86fspp.apps.googleusercontent.com", ) ) - // Email/Password Sign-in - provider( - AuthProvider.Email( - isDisplayNameRequired = true, - isEmailLinkSignInEnabled = false, - isNewAccountsAllowed = true, - minimumPasswordLength = 8, - passwordValidationRules = listOf( - PasswordRule.MinimumLength(8), - PasswordRule.RequireLowercase, - PasswordRule.RequireUppercase, - ), - emailLinkActionCodeSettings = null, - buttonLabel = "Sign in with Email" - ) - ) - // Email Link Sign-in (passwordless) provider( AuthProvider.Email( isDisplayNameRequired = true, @@ -94,7 +77,6 @@ class HighLevelApiDemoActivity : ComponentActivity() { PasswordRule.RequireLowercase, PasswordRule.RequireUppercase, ), - buttonLabel = "Sign in with Email Link" ) ) provider( diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt index d5d509da4..ed74955ea 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt @@ -558,6 +558,7 @@ class EmailAuthScreenTest { onEmailChange = state.onEmailChange, onPasswordChange = state.onPasswordChange, onSignInClick = state.onSignInClick, + onSignInWithEmailLink = state.onSignInEmailLinkClick, onGoToSignUp = state.onGoToSignUp, onGoToResetPassword = state.onGoToResetPassword, ) From 5f515b1eb67cfa8eb8d565419255af8288ce0af1 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Fri, 31 Oct 2025 16:48:40 +0100 Subject: [PATCH 2/7] fix: email link sign in and tests --- .../compose/ui/screens/FirebaseAuthScreen.kt | 5 - .../ui/screens/email/EmailAuthScreen.kt | 78 ++--- .../ui/screens/email/SignInEmailLinkUI.kt | 275 ++++++++++++++++++ .../auth/compose/ui/screens/email/SignInUI.kt | 156 +++------- composeapp/build.gradle.kts | 10 + .../CustomSlotsThemingDemoActivity.kt | 4 +- .../com/firebase/composeapp/MainActivity.kt | 2 +- .../ui/auth/compose/ui/AccessibilityTest.kt | 4 +- .../compose/ui/screens/EmailAuthScreenTest.kt | 22 +- 9 files changed, 402 insertions(+), 154 deletions(-) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInEmailLinkUI.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt index 0d5891987..16fa9ba8b 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt @@ -446,11 +446,6 @@ fun FirebaseAuthScreen( } catch (e: Exception) { Log.e("FirebaseAuthScreen", "Failed to complete email link sign-in", e) } - - // Navigate to Email auth screen for cross-device error handling - if (navController.currentBackStackEntry?.destination?.route != AuthRoute.Email.route) { - navController.navigate(AuthRoute.Email.route) - } } } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt index 9abd3a8b8..11db03107 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt @@ -42,6 +42,7 @@ import kotlinx.coroutines.launch enum class EmailAuthMode { SignIn, + EmailLinkSignIn, SignUp, ResetPassword, } @@ -103,6 +104,7 @@ class EmailAuthContentState( val onGoToSignUp: () -> Unit, val onGoToSignIn: () -> Unit, val onGoToResetPassword: () -> Unit, + val onGoToEmailLinkSignIn: () -> Unit, ) /** @@ -133,7 +135,13 @@ fun EmailAuthScreen( val dialogController = LocalTopLevelDialogController.current val coroutineScope = rememberCoroutineScope() - val mode = rememberSaveable { mutableStateOf(EmailAuthMode.SignIn) } + // Start in EmailLinkSignIn mode if coming from cross-device flow + val initialMode = if (emailLinkFromDifferentDevice != null && provider.isEmailLinkSignInEnabled) { + EmailAuthMode.EmailLinkSignIn + } else { + EmailAuthMode.SignIn + } + val mode = rememberSaveable { mutableStateOf(initialMode) } val displayNameValue = rememberSaveable { mutableStateOf("") } val emailTextValue = rememberSaveable { mutableStateOf("") } val passwordTextValue = rememberSaveable { mutableStateOf("") } @@ -222,37 +230,13 @@ fun EmailAuthScreen( onSignInClick = { coroutineScope.launch { try { - when { - emailLinkFromDifferentDevice != null -> { - authUI.signInWithEmailLink( - context = context, - config = configuration, - provider = provider, - email = emailTextValue.value, - emailLink = emailLinkFromDifferentDevice, - ) - } - - provider.isEmailLinkSignInEnabled -> { - authUI.sendSignInLinkToEmail( - context = context, - config = configuration, - provider = provider, - email = emailTextValue.value, - credentialForLinking = authCredentialForLinking, - ) - } - - else -> { - authUI.signInWithEmailAndPassword( - context = context, - config = configuration, - email = emailTextValue.value, - password = passwordTextValue.value, - credentialForLinking = authCredentialForLinking, - ) - } - } + authUI.signInWithEmailAndPassword( + context = context, + config = configuration, + email = emailTextValue.value, + password = passwordTextValue.value, + credentialForLinking = authCredentialForLinking, + ) } catch (e: Exception) { onError(AuthException.from(e)) } @@ -261,7 +245,15 @@ fun EmailAuthScreen( onSignInEmailLinkClick = { coroutineScope.launch { try { - if (provider.isEmailLinkSignInEnabled) { + if (emailLinkFromDifferentDevice != null) { + authUI.signInWithEmailLink( + context = context, + config = configuration, + provider = provider, + email = emailTextValue.value, + emailLink = emailLinkFromDifferentDevice, + ) + } else { authUI.sendSignInLinkToEmail( context = context, config = configuration, @@ -315,6 +307,10 @@ fun EmailAuthScreen( textValues.forEach { it.value = "" } mode.value = EmailAuthMode.ResetPassword }, + onGoToEmailLinkSignIn = { + textValues.forEach { it.value = "" } + mode.value = EmailAuthMode.EmailLinkSignIn + }, ) if (content != null) { @@ -345,9 +341,23 @@ private fun DefaultEmailAuthContent( onEmailChange = state.onEmailChange, onPasswordChange = state.onPasswordChange, onSignInClick = state.onSignInClick, - onSignInWithEmailLink = state.onSignInEmailLinkClick, onGoToSignUp = state.onGoToSignUp, onGoToResetPassword = state.onGoToResetPassword, + onGoToEmailLinkSignIn = state.onGoToEmailLinkSignIn, + onNavigateBack = onCancel + ) + } + + EmailAuthMode.EmailLinkSignIn -> { + SignInEmailLinkUI( + configuration = configuration, + email = state.email, + isLoading = state.isLoading, + emailSignInLinkSent = state.emailSignInLinkSent, + onEmailChange = state.onEmailChange, + onSignInWithEmailLink = state.onSignInEmailLinkClick, + onGoToSignIn = state.onGoToSignIn, + onGoToResetPassword = state.onGoToResetPassword, onNavigateBack = onCancel ) } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInEmailLinkUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInEmailLinkUI.kt new file mode 100644 index 000000000..0bb8e839d --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInEmailLinkUI.kt @@ -0,0 +1,275 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.firebase.ui.auth.compose.ui.screens + +import androidx.compose.foundation.layout.Column +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration +import com.firebase.ui.auth.compose.configuration.authUIConfiguration +import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider +import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme +import com.firebase.ui.auth.compose.configuration.validators.EmailValidator +import com.firebase.ui.auth.compose.ui.components.AuthTextField +import com.firebase.ui.auth.compose.ui.components.TermsAndPrivacyForm +import com.google.firebase.auth.actionCodeSettings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SignInEmailLinkUI( + modifier: Modifier = Modifier, + configuration: AuthUIConfiguration, + isLoading: Boolean, + emailSignInLinkSent: Boolean, + email: String, + onEmailChange: (String) -> Unit, + onSignInWithEmailLink: () -> Unit, + onGoToSignIn: () -> Unit, + onGoToResetPassword: () -> Unit, + onNavigateBack: (() -> Unit)? = null, +) { + val provider = configuration.providers.filterIsInstance().first() + val stringProvider = LocalAuthUIStringProvider.current + val emailValidator = remember { EmailValidator(stringProvider) } + + val isFormValid = remember(email) { + derivedStateOf { + emailValidator.validate(email) + } + } + + if (provider.isEmailLinkSignInEnabled) { + val isDialogVisible = + remember(emailSignInLinkSent) { mutableStateOf(emailSignInLinkSent) } + + if (isDialogVisible.value) { + AlertDialog( + title = { + Text( + text = stringProvider.emailSignInLinkSentDialogTitle, + style = MaterialTheme.typography.headlineSmall + ) + }, + text = { + Text( + text = stringProvider.emailSignInLinkSentDialogBody(email), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start + ) + }, + confirmButton = { + TextButton( + onClick = { + isDialogVisible.value = false + } + ) { + Text(stringProvider.dismissAction) + } + }, + onDismissRequest = { + isDialogVisible.value = false + }, + ) + } + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text( + text = stringProvider.signInDefault, + modifier = Modifier.semantics { heading() } + ) + }, + navigationIcon = { + if (onNavigateBack != null) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringProvider.backAction + ) + } + } + }, + colors = AuthUITheme.topAppBarColors + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .safeDrawingPadding() + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + ) { + AuthTextField( + value = email, + validator = emailValidator, + enabled = !isLoading, + label = { + Text(stringProvider.emailHint) + }, + onValueChange = { text -> + onEmailChange(text) + } + ) + Spacer(modifier = Modifier.height(16.dp)) + TextButton( + modifier = Modifier + .align(Alignment.Start), + onClick = { + onGoToResetPassword() + }, + enabled = !isLoading, + contentPadding = PaddingValues.Zero + ) { + Text( + modifier = modifier, + text = stringProvider.troubleSigningIn, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = { + onSignInWithEmailLink() + }, + modifier = Modifier.align(Alignment.End), + enabled = !isLoading && isFormValid.value, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp) + ) + } else { + Text(stringProvider.signInDefault.uppercase()) + } + } + + // Show toggle to go back to password mode + Spacer(modifier = Modifier.height(64.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + HorizontalDivider(modifier = Modifier.weight(1f)) + Text( + text = "or Continue with", + modifier = Modifier.padding(horizontal = 8.dp), + style = MaterialTheme.typography.bodySmall + ) + HorizontalDivider(modifier = Modifier.weight(1f)) + } + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = { + onGoToSignIn() + }, + modifier = Modifier.fillMaxWidth(), + enabled = !isLoading + ) { + Text("Sign in with password".uppercase()) + } + + Spacer(modifier = Modifier.height(16.dp)) + TermsAndPrivacyForm( + modifier = Modifier.align(Alignment.End), + tosUrl = configuration.tosUrl, + ppUrl = configuration.privacyPolicyUrl, + ) + } + } +} + +@Preview +@Composable +fun PreviewSignInEmailLinkUI() { + val applicationContext = LocalContext.current + val provider = AuthProvider.Email( + isDisplayNameRequired = true, + isEmailLinkSignInEnabled = true, + isEmailLinkForceSameDeviceEnabled = true, + emailLinkActionCodeSettings = actionCodeSettings { + url = "https://fake-project-id.firebaseapp.com" + handleCodeInApp = true + setAndroidPackageName( + "fake.project.id", + true, + null + ) + }, + isNewAccountsAllowed = true, + minimumPasswordLength = 8, + passwordValidationRules = listOf() + ) + + AuthUITheme { + SignInEmailLinkUI( + configuration = authUIConfiguration { + context = applicationContext + providers { provider(provider) } + tosUrl = "" + privacyPolicyUrl = "" + }, + email = "", + isLoading = false, + emailSignInLinkSent = false, + onEmailChange = { email -> }, + onSignInWithEmailLink = {}, + onGoToSignIn = {}, + onGoToResetPassword = {}, + ) + } +} diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInUI.kt index 9c256617b..9ccf0a968 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignInUI.kt @@ -63,7 +63,6 @@ import com.firebase.ui.auth.compose.configuration.validators.EmailValidator import com.firebase.ui.auth.compose.configuration.validators.PasswordValidator import com.firebase.ui.auth.compose.ui.components.AuthTextField import com.firebase.ui.auth.compose.ui.components.TermsAndPrivacyForm -import com.google.firebase.auth.AuthCredential @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -77,69 +76,27 @@ fun SignInUI( onEmailChange: (String) -> Unit, onPasswordChange: (String) -> Unit, onSignInClick: () -> Unit, - onSignInWithEmailLink: () -> Unit, onGoToSignUp: () -> Unit, onGoToResetPassword: () -> Unit, + onGoToEmailLinkSignIn: () -> Unit, onNavigateBack: (() -> Unit)? = null, ) { val provider = configuration.providers.filterIsInstance().first() val stringProvider = LocalAuthUIStringProvider.current - - // Local state to track if user chose email link sign-in - val isEmailLinkMode = remember { mutableStateOf(false) } - val emailValidator = remember { EmailValidator(stringProvider) } - val passwordValidator = remember(isEmailLinkMode.value) { + val passwordValidator = remember { PasswordValidator(stringProvider = stringProvider, rules = emptyList()) } - val isFormValid = remember(email, password, isEmailLinkMode.value) { + val isFormValid = remember(email, password) { derivedStateOf { listOf( emailValidator.validate(email), - if (!isEmailLinkMode.value) - passwordValidator.validate(password) else true, + passwordValidator.validate(password) ).all { it } } } - if (provider.isEmailLinkSignInEnabled) { - val isDialogVisible = - remember(emailSignInLinkSent) { mutableStateOf(emailSignInLinkSent) } - - if (isDialogVisible.value) { - AlertDialog( - title = { - Text( - text = stringProvider.emailSignInLinkSentDialogTitle, - style = MaterialTheme.typography.headlineSmall - ) - }, - text = { - Text( - text = stringProvider.emailSignInLinkSentDialogBody(email), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Start - ) - }, - confirmButton = { - TextButton( - onClick = { - isDialogVisible.value = false - } - ) { - Text(stringProvider.dismissAction) - } - }, - onDismissRequest = { - isDialogVisible.value = false - }, - ) - } - } - - val layoutDirection = LocalLayoutDirection.current - Scaffold( modifier = modifier, topBar = { @@ -183,67 +140,56 @@ fun SignInUI( } ) Spacer(modifier = Modifier.height(16.dp)) - if (!isEmailLinkMode.value) { - AuthTextField( - value = password, - validator = passwordValidator, - enabled = !isLoading, - isSecureTextField = true, - label = { - Text(stringProvider.passwordHint) - }, - onValueChange = { text -> - onPasswordChange(text) - } - ) - Spacer(modifier = Modifier.height(8.dp)) - } - if (!isEmailLinkMode.value) { - TextButton( - modifier = Modifier - .align(Alignment.Start), - onClick = { - onGoToResetPassword() - }, - enabled = !isLoading, - contentPadding = PaddingValues.Zero - ) { - Text( - modifier = modifier, - text = stringProvider.troubleSigningIn, - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, - textDecoration = TextDecoration.Underline - ) + AuthTextField( + value = password, + validator = passwordValidator, + enabled = !isLoading, + isSecureTextField = true, + label = { + Text(stringProvider.passwordHint) + }, + onValueChange = { text -> + onPasswordChange(text) } - Spacer(modifier = Modifier.height(8.dp)) + ) + Spacer(modifier = Modifier.height(8.dp)) + TextButton( + modifier = Modifier + .align(Alignment.Start), + onClick = { + onGoToResetPassword() + }, + enabled = !isLoading, + contentPadding = PaddingValues.Zero + ) { + Text( + modifier = modifier, + text = stringProvider.troubleSigningIn, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline + ) } + Spacer(modifier = Modifier.height(8.dp)) Row( modifier = Modifier .align(Alignment.End), ) { - // Signup is hidden for email link sign in - if (!isEmailLinkMode.value) { - Button( - onClick = { - onGoToSignUp() - }, - enabled = !isLoading, - ) { - Text(stringProvider.signupPageTitle.uppercase()) - } - Spacer(modifier = Modifier.width(16.dp)) + Button( + onClick = { + onGoToSignUp() + }, + enabled = !isLoading, + ) { + Text(stringProvider.signupPageTitle.uppercase()) } + Spacer(modifier = Modifier.width(16.dp)) Button( onClick = { // TODO(demolaf): When signIn is fired if Exception is UserNotFound // then we check if provider.isNewAccountsAllowed then we show signUp // else we show an error dialog stating signup is not allowed - if (!isEmailLinkMode.value) { - onSignInClick() - } else { - onSignInWithEmailLink() - } + onSignInClick() }, enabled = !isLoading && isFormValid.value, ) { @@ -258,7 +204,7 @@ fun SignInUI( } } - // Show toggle between password and email link sign-in + // Show toggle to email link sign-in if (provider.isEmailLinkSignInEnabled) { Spacer(modifier = Modifier.height(64.dp)) Row( @@ -276,22 +222,12 @@ fun SignInUI( Spacer(modifier = Modifier.height(24.dp)) Button( onClick = { - isEmailLinkMode.value = !isEmailLinkMode.value - if (isEmailLinkMode.value) { - // Switching to email link mode: clear password - onPasswordChange("") - } + onGoToEmailLinkSignIn() }, modifier = Modifier.fillMaxWidth(), enabled = !isLoading ) { - Text( - if (isEmailLinkMode.value) { - "Sign in with password".uppercase() - } else { - "Sign in with email link".uppercase() - } - ) + Text("Sign in with email link".uppercase()) } } @@ -334,9 +270,9 @@ fun PreviewSignInUI() { onEmailChange = { email -> }, onPasswordChange = { password -> }, onSignInClick = {}, - onSignInWithEmailLink = {}, onGoToSignUp = {}, onGoToResetPassword = {}, + onGoToEmailLinkSignIn = {}, ) } } diff --git a/composeapp/build.gradle.kts b/composeapp/build.gradle.kts index fc2545b0d..2e682bb28 100644 --- a/composeapp/build.gradle.kts +++ b/composeapp/build.gradle.kts @@ -19,6 +19,15 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + signingConfigs { + getByName("debug") { + storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } + buildTypes { release { isMinifyEnabled = false @@ -26,6 +35,7 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) + signingConfig = signingConfigs.getByName("debug") } } compileOptions { diff --git a/composeapp/src/main/java/com/firebase/composeapp/CustomSlotsThemingDemoActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/CustomSlotsThemingDemoActivity.kt index f746b333f..75e9cb7d5 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/CustomSlotsThemingDemoActivity.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/CustomSlotsThemingDemoActivity.kt @@ -316,7 +316,7 @@ fun CustomEmailAuthUI(state: EmailAuthContentState) { // Title based on mode Text( text = when (state.mode) { - EmailAuthMode.SignIn -> "📧 Welcome Back" + EmailAuthMode.SignIn, EmailAuthMode.EmailLinkSignIn -> "📧 Welcome Back" EmailAuthMode.SignUp -> "📧 Create Account" EmailAuthMode.ResetPassword -> "📧 Reset Password" }, @@ -345,7 +345,7 @@ fun CustomEmailAuthUI(state: EmailAuthContentState) { // Render UI based on mode when (state.mode) { - EmailAuthMode.SignIn -> SignInUI(state) + EmailAuthMode.SignIn, EmailAuthMode.EmailLinkSignIn -> SignInUI(state) EmailAuthMode.SignUp -> SignUpUI(state) EmailAuthMode.ResetPassword -> ResetPasswordUI(state) } diff --git a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt index 3ce5d005b..039e20115 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt @@ -36,7 +36,7 @@ import com.google.firebase.FirebaseApp */ class MainActivity : ComponentActivity() { companion object { - private const val USE_AUTH_EMULATOR = true + private const val USE_AUTH_EMULATOR = false private const val AUTH_EMULATOR_HOST = "10.0.2.2" private const val AUTH_EMULATOR_PORT = 9099 } diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/AccessibilityTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/AccessibilityTest.kt index 65bb018c3..d14ce420c 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/AccessibilityTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/AccessibilityTest.kt @@ -189,8 +189,9 @@ class AccessibilityTest { onEmailChange = {}, onPasswordChange = {}, onSignInClick = {}, + onGoToEmailLinkSignIn = {}, onGoToSignUp = {}, - onGoToResetPassword = {} + onGoToResetPassword = {}, ) } } @@ -279,6 +280,7 @@ class AccessibilityTest { onEmailChange = {}, onPasswordChange = {}, onSignInClick = {}, + onGoToEmailLinkSignIn = {}, onGoToSignUp = {}, onGoToResetPassword = {} ) diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt index ed74955ea..6f85c1260 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreenTest.kt @@ -487,6 +487,13 @@ class EmailAuthScreenTest { composeTestRule.onNodeWithText(stringProvider.signInDefault) .assertIsDisplayed() + + // Click "Sign in with email link" button to switch to email link mode + composeTestRule.onNodeWithText("SIGN IN WITH EMAIL LINK") + .performScrollTo() + .assertIsDisplayed() + .performClick() + composeTestRule.onNodeWithText(stringProvider.emailHint) .performScrollTo() .assertIsDisplayed() @@ -558,9 +565,22 @@ class EmailAuthScreenTest { onEmailChange = state.onEmailChange, onPasswordChange = state.onPasswordChange, onSignInClick = state.onSignInClick, - onSignInWithEmailLink = state.onSignInEmailLinkClick, onGoToSignUp = state.onGoToSignUp, onGoToResetPassword = state.onGoToResetPassword, + onGoToEmailLinkSignIn = state.onGoToEmailLinkSignIn, + ) + } + + EmailAuthMode.EmailLinkSignIn -> { + SignInEmailLinkUI( + configuration = configuration, + email = state.email, + isLoading = state.isLoading, + emailSignInLinkSent = state.emailSignInLinkSent, + onEmailChange = state.onEmailChange, + onSignInWithEmailLink = state.onSignInEmailLinkClick, + onGoToSignIn = state.onGoToSignIn, + onGoToResetPassword = state.onGoToResetPassword, ) } From bb0c195d5e876d9b40cfe4dd224483f4667a7ad2 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Sat, 1 Nov 2025 17:12:01 +0100 Subject: [PATCH 3/7] update facebook brand --- .../theme/ProviderStyleDefaults.kt | 2 +- .../ui/components/AuthProviderButton.kt | 5 +++++ .../drawable/fui_ic_facebook_white_22dp.xml | 20 ++++++++----------- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt index a0ea457a7..115823a13 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/theme/ProviderStyleDefaults.kt @@ -43,7 +43,7 @@ internal object ProviderStyleDefaults { Provider.FACEBOOK -> { provider.id to AuthUITheme.ProviderStyle( icon = AuthUIAsset.Resource(R.drawable.fui_ic_facebook_white_22dp), - backgroundColor = Color(0xFF3B5998), + backgroundColor = Color(0xFF1877F2), contentColor = Color.White ) } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt index b0ad943db..cb0e71a63 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButton.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -106,12 +107,16 @@ fun AuthProviderButton( val iconTint = providerStyle.iconTint if (iconTint != null) { Icon( + modifier = Modifier + .size(24.dp), painter = providerIcon.painter, contentDescription = providerLabel, tint = iconTint ) } else { Image( + modifier = Modifier + .size(24.dp), painter = providerIcon.painter, contentDescription = providerLabel ) diff --git a/auth/src/main/res/drawable/fui_ic_facebook_white_22dp.xml b/auth/src/main/res/drawable/fui_ic_facebook_white_22dp.xml index 4aee44997..85afe860d 100644 --- a/auth/src/main/res/drawable/fui_ic_facebook_white_22dp.xml +++ b/auth/src/main/res/drawable/fui_ic_facebook_white_22dp.xml @@ -1,13 +1,9 @@ - - - + + From 829770be580711a3f072115efe2ee94bf2974920 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Mon, 3 Nov 2025 08:16:57 +0100 Subject: [PATCH 4/7] fix ci --- composeapp/build.gradle.kts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/composeapp/build.gradle.kts b/composeapp/build.gradle.kts index 2e682bb28..c76f2ed86 100644 --- a/composeapp/build.gradle.kts +++ b/composeapp/build.gradle.kts @@ -19,15 +19,6 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } - signingConfigs { - getByName("debug") { - storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore") - storePassword = "android" - keyAlias = "androiddebugkey" - keyPassword = "android" - } - } - buildTypes { release { isMinifyEnabled = false @@ -35,7 +26,11 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) - signingConfig = signingConfigs.getByName("debug") + // Only sign with debug keystore if it exists (for local testing) + val debugKeystoreFile = file("${System.getProperty("user.home")}/.android/debug.keystore") + if (debugKeystoreFile.exists()) { + signingConfig = signingConfigs.getByName("debug") + } } } compileOptions { From 5f967bd59dad28801bb338188a2a6e17da5d0682 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Mon, 3 Nov 2025 08:34:24 +0100 Subject: [PATCH 5/7] fix: first and last name before email --- .../auth/compose/ui/screens/email/SignUpUI.kt | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignUpUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignUpUI.kt index 539b74c5b..655b2c5f6 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignUpUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/SignUpUI.kt @@ -116,18 +116,6 @@ fun SignUpUI( .padding(horizontal = 16.dp) .verticalScroll(rememberScrollState()), ) { - AuthTextField( - value = email, - validator = emailValidator, - enabled = !isLoading, - label = { - Text(stringProvider.emailHint) - }, - onValueChange = { text -> - onEmailChange(text) - } - ) - Spacer(modifier = Modifier.height(16.dp)) if (provider.isDisplayNameRequired) { AuthTextField( value = displayName, @@ -142,6 +130,18 @@ fun SignUpUI( ) Spacer(modifier = Modifier.height(16.dp)) } + AuthTextField( + value = email, + validator = emailValidator, + enabled = !isLoading, + label = { + Text(stringProvider.emailHint) + }, + onValueChange = { text -> + onEmailChange(text) + } + ) + Spacer(modifier = Modifier.height(16.dp)) AuthTextField( value = password, validator = passwordValidator, From b44a69a38a72792c775972a610e095804521c973 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Mon, 3 Nov 2025 09:57:18 +0100 Subject: [PATCH 6/7] track shown error states --- .../ui/components/TopLevelDialogController.kt | 24 +++++++++++++++---- .../compose/ui/screens/FirebaseAuthScreen.kt | 2 +- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/TopLevelDialogController.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/TopLevelDialogController.kt index 35893fb0a..973d6e6d5 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/TopLevelDialogController.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/TopLevelDialogController.kt @@ -21,6 +21,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.firebase.ui.auth.compose.AuthException +import com.firebase.ui.auth.compose.AuthState import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider /** @@ -66,12 +67,15 @@ val LocalTopLevelDialogController = compositionLocalOf(null) + private val shownErrorStates = mutableSetOf() /** * Shows an error recovery dialog at the top level using [ErrorRecoveryDialog]. + * Automatically prevents duplicate dialogs for the same AuthState.Error instance. * * @param exception The auth exception to display * @param onRetry Callback when user clicks retry button @@ -84,6 +88,17 @@ class TopLevelDialogController( onRecover: (AuthException) -> Unit = {}, onDismiss: () -> Unit = {} ) { + // Get current error state + val currentErrorState = authState as? AuthState.Error + + // If this exact error state has already been shown, skip + if (currentErrorState != null && currentErrorState in shownErrorStates) { + return + } + + // Mark this error state as shown + currentErrorState?.let { shownErrorStates.add(it) } + dialogState = DialogState.ErrorDialog( exception = exception, onRetry = onRetry, @@ -148,9 +163,10 @@ class TopLevelDialogController( */ @Composable fun rememberTopLevelDialogController( - stringProvider: AuthUIStringProvider + stringProvider: AuthUIStringProvider, + authState: AuthState ): TopLevelDialogController { - return remember(stringProvider) { - TopLevelDialogController(stringProvider) + return remember(stringProvider, authState) { + TopLevelDialogController(stringProvider, authState) } } diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt index 16fa9ba8b..6cb2dc6f6 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt @@ -97,9 +97,9 @@ fun FirebaseAuthScreen( val coroutineScope = rememberCoroutineScope() val stringProvider = DefaultAuthUIStringProvider(context) val navController = rememberNavController() - val dialogController = rememberTopLevelDialogController(stringProvider) val authState by authUI.authStateFlow().collectAsState(AuthState.Idle) + val dialogController = rememberTopLevelDialogController(stringProvider, authState) val lastSuccessfulUserId = remember { mutableStateOf(null) } val pendingLinkingCredential = remember { mutableStateOf(null) } val pendingResolver = remember { mutableStateOf(null) } From cae4550b49e0583c8610ec7f5779a0e7fd254d39 Mon Sep 17 00:00:00 2001 From: Ademola Fadumo Date: Mon, 3 Nov 2025 09:57:28 +0100 Subject: [PATCH 7/7] add back button to sign in with phone --- .../compose/ui/screens/phone/EnterPhoneNumberUI.kt | 13 +++++++++++++ .../compose/ui/screens/phone/PhoneAuthScreen.kt | 9 ++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/phone/EnterPhoneNumberUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/phone/EnterPhoneNumberUI.kt index 36e628f6a..2a56ffde4 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/phone/EnterPhoneNumberUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/phone/EnterPhoneNumberUI.kt @@ -27,12 +27,14 @@ 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.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckBox import androidx.compose.material.icons.filled.CheckBoxOutlineBlank import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -70,6 +72,7 @@ fun EnterPhoneNumberUI( onCountrySelected: (CountryData) -> Unit, onSendCodeClick: () -> Unit, title: String? = null, + onNavigateBack: (() -> Unit)? = null, ) { val context = LocalContext.current val provider = configuration.providers.filterIsInstance().first() @@ -91,6 +94,16 @@ fun EnterPhoneNumberUI( title = { Text(title ?: stringProvider.signInWithPhone) }, + navigationIcon = { + if (onNavigateBack != null) { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringProvider.backAction + ) + } + } + }, colors = AuthUITheme.topAppBarColors ) }, diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/phone/PhoneAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/phone/PhoneAuthScreen.kt index 970742507..47998014c 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/phone/PhoneAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/phone/PhoneAuthScreen.kt @@ -308,7 +308,8 @@ fun PhoneAuthScreen( } else { DefaultPhoneAuthContent( configuration = configuration, - state = state + state = state, + onCancel = onCancel ) } } @@ -316,7 +317,8 @@ fun PhoneAuthScreen( @Composable private fun DefaultPhoneAuthContent( configuration: AuthUIConfiguration, - state: PhoneAuthContentState + state: PhoneAuthContentState, + onCancel: () -> Unit, ) { when (state.step) { PhoneAuthStep.EnterPhoneNumber -> { @@ -327,7 +329,8 @@ private fun DefaultPhoneAuthContent( selectedCountry = state.selectedCountry, onPhoneNumberChange = state.onPhoneNumberChange, onCountrySelected = state.onCountrySelected, - onSendCodeClick = state.onSendCodeClick + onSendCodeClick = state.onSendCodeClick, + onNavigateBack = onCancel ) }