diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/AuthException.kt b/auth/src/main/java/com/firebase/ui/auth/compose/AuthException.kt index baa81b753..da58b39e6 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/AuthException.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/AuthException.kt @@ -327,6 +327,9 @@ abstract class AuthException( @JvmStatic fun from(firebaseException: Exception): AuthException { return when (firebaseException) { + // If already an AuthException, return it directly + is AuthException -> firebaseException + // Handle specific Firebase Auth exceptions first (before general FirebaseException) is FirebaseAuthInvalidCredentialsException -> { InvalidCredentialsException( 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 new file mode 100644 index 000000000..35893fb0a --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/TopLevelDialogController.kt @@ -0,0 +1,156 @@ +/* + * 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.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +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.configuration.string_provider.AuthUIStringProvider + +/** + * CompositionLocal for accessing the top-level dialog controller from any composable. + */ +val LocalTopLevelDialogController = compositionLocalOf { + null +} + +/** + * A top-level dialog controller that allows any child composable to show error recovery dialogs. + * + * It provides a single point of control for showing dialogs from anywhere in the composition tree, + * preventing duplicate dialogs when multiple screens observe the same error state. + * + * **Usage:** + * ```kotlin + * // At the root of your auth flow (FirebaseAuthScreen): + * val dialogController = rememberTopLevelDialogController(stringProvider) + * + * CompositionLocalProvider(LocalTopLevelDialogController provides dialogController) { + * // Your auth screens... + * + * // Show dialog at root level (only one instance) + * dialogController.CurrentDialog() + * } + * + * // In any child screen (EmailAuthScreen, PhoneAuthScreen, etc.): + * val dialogController = LocalTopLevelDialogController.current + * + * LaunchedEffect(error) { + * error?.let { exception -> + * dialogController?.showErrorDialog( + * exception = exception, + * onRetry = { ... }, + * onRecover = { ... }, + * onDismiss = { ... } + * ) + * } + * } + * ``` + * + * @since 10.0.0 + */ +class TopLevelDialogController( + private val stringProvider: AuthUIStringProvider +) { + private var dialogState by mutableStateOf(null) + + /** + * Shows an error recovery dialog at the top level using [ErrorRecoveryDialog]. + * + * @param exception The auth exception to display + * @param onRetry Callback when user clicks retry button + * @param onRecover Callback when user clicks recover button (e.g., navigate to different screen) + * @param onDismiss Callback when dialog is dismissed + */ + fun showErrorDialog( + exception: AuthException, + onRetry: (AuthException) -> Unit = {}, + onRecover: (AuthException) -> Unit = {}, + onDismiss: () -> Unit = {} + ) { + dialogState = DialogState.ErrorDialog( + exception = exception, + onRetry = onRetry, + onRecover = onRecover, + onDismiss = { + dialogState = null + onDismiss() + } + ) + } + + /** + * Dismisses the currently shown dialog. + */ + fun dismissDialog() { + dialogState = null + } + + /** + * Composable that renders the current dialog, if any. + * This should be called once at the root level of your auth flow. + * + * Uses the existing [ErrorRecoveryDialog] component. + */ + @Composable + fun CurrentDialog() { + val state = dialogState + when (state) { + is DialogState.ErrorDialog -> { + ErrorRecoveryDialog( + error = state.exception, + stringProvider = stringProvider, + onRetry = { exception -> + state.onRetry(exception) + state.onDismiss() + }, + onRecover = { exception -> + state.onRecover(exception) + state.onDismiss() + }, + onDismiss = state.onDismiss + ) + } + null -> { + // No dialog to show + } + } + } + + private sealed class DialogState { + data class ErrorDialog( + val exception: AuthException, + val onRetry: (AuthException) -> Unit, + val onRecover: (AuthException) -> Unit, + val onDismiss: () -> Unit + ) : DialogState() + } +} + +/** + * Creates and remembers a [TopLevelDialogController]. + */ +@Composable +fun rememberTopLevelDialogController( + stringProvider: AuthUIStringProvider +): TopLevelDialogController { + return remember(stringProvider) { + TopLevelDialogController(stringProvider) + } +} 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 f868b58a3..48864c3c6 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 @@ -59,7 +59,8 @@ import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailL import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider -import com.firebase.ui.auth.compose.ui.components.ErrorRecoveryDialog +import com.firebase.ui.auth.compose.ui.components.LocalTopLevelDialogController +import com.firebase.ui.auth.compose.ui.components.rememberTopLevelDialogController import com.firebase.ui.auth.compose.ui.method_picker.AuthMethodPicker import com.firebase.ui.auth.compose.ui.screens.phone.PhoneAuthScreen import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager @@ -96,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 isErrorDialogVisible = remember(authState) { mutableStateOf(authState is AuthState.Error) } val lastSuccessfulUserId = remember { mutableStateOf(null) } val pendingLinkingCredential = remember { mutableStateOf(null) } val pendingResolver = remember { mutableStateOf(null) } @@ -192,7 +193,10 @@ fun FirebaseAuthScreen( ) } - CompositionLocalProvider(LocalAuthUIStringProvider provides configuration.stringProvider) { + CompositionLocalProvider( + LocalAuthUIStringProvider provides configuration.stringProvider, + LocalTopLevelDialogController provides dialogController + ) { Surface( modifier = Modifier .fillMaxSize() @@ -393,7 +397,7 @@ fun FirebaseAuthScreen( try { // Try to retrieve saved email from DataStore (same-device flow) val savedEmail = EmailLinkPersistenceManager.default.retrieveSessionRecord(context)?.email - + if (savedEmail != null) { // Same device - we have the email, sign in automatically authUI.signInWithEmailLink( @@ -418,7 +422,7 @@ 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) @@ -501,31 +505,30 @@ fun FirebaseAuthScreen( } } + // Handle errors using top-level dialog controller val errorState = authState as? AuthState.Error - if (isErrorDialogVisible.value && errorState != null) { - ErrorRecoveryDialog( - error = when (val throwable = errorState.exception) { + if (errorState != null) { + LaunchedEffect(errorState) { + val exception = when (val throwable = errorState.exception) { is AuthException -> throwable else -> AuthException.from(throwable) - }, - stringProvider = stringProvider, - onRetry = { exception -> - when (exception) { - is AuthException.InvalidCredentialsException -> Unit - else -> Unit - } - isErrorDialogVisible.value = false - }, - onRecover = { exception -> - when (exception) { - is AuthException.EmailAlreadyInUseException -> { - navController.navigate(AuthRoute.Email.route) { - launchSingleTop = true + } + + dialogController.showErrorDialog( + exception = exception, + onRetry = { _ -> + // Child screens handle their own retry logic + }, + onRecover = { exception -> + when (exception) { + is AuthException.EmailAlreadyInUseException -> { + navController.navigate(AuthRoute.Email.route) { + launchSingleTop = true + } } - } - is AuthException.AccountLinkingRequiredException -> { - pendingLinkingCredential.value = exception.credential + is AuthException.AccountLinkingRequiredException -> { + pendingLinkingCredential.value = exception.credential navController.navigate(AuthRoute.Email.route) { launchSingleTop = true } @@ -547,16 +550,19 @@ fun FirebaseAuthScreen( } } - else -> Unit + else -> Unit + } + }, + onDismiss = { + // Dialog dismissed } - isErrorDialogVisible.value = false - }, - onDismiss = { - isErrorDialogVisible.value = false - } - ) + ) + } } + // Render the top-level dialog (only one instance) + dialogController.CurrentDialog() + val loadingState = authState as? AuthState.Loading if (loadingState != null) { LoadingDialog(loadingState.message ?: stringProvider.progressDialogLoading) 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 f16401adb..2e9ab97e3 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 @@ -35,7 +35,7 @@ import com.firebase.ui.auth.compose.configuration.auth_provider.sendSignInLinkTo import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailAndPassword import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailLink import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider -import com.firebase.ui.auth.compose.ui.components.ErrorRecoveryDialog +import com.firebase.ui.auth.compose.ui.components.LocalTopLevelDialogController import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.AuthResult import kotlinx.coroutines.launch @@ -129,6 +129,7 @@ fun EmailAuthScreen( ) { val provider = configuration.providers.filterIsInstance().first() val stringProvider = LocalAuthUIStringProvider.current + val dialogController = LocalTopLevelDialogController.current val coroutineScope = rememberCoroutineScope() val mode = rememberSaveable { mutableStateOf(EmailAuthMode.SignIn) } @@ -153,9 +154,6 @@ fun EmailAuthScreen( val resetLinkSent = authState is AuthState.PasswordResetLinkSent val emailSignInLinkSent = authState is AuthState.EmailSignInLinkSent - val isErrorDialogVisible = - remember(authState) { mutableStateOf(authState is AuthState.Error) } - LaunchedEffect(authState) { Log.d("EmailAuthScreen", "Current state: $authState") when (val state = authState) { @@ -166,7 +164,34 @@ fun EmailAuthScreen( } is AuthState.Error -> { - onError(AuthException.from(state.exception)) + 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 + } + }, + onDismiss = { + // Dialog dismissed + } + ) + } } is AuthState.Cancelled -> { @@ -280,29 +305,6 @@ fun EmailAuthScreen( } ) - if (isErrorDialogVisible.value && - (authState as AuthState.Error).exception !is AuthException.AccountLinkingRequiredException - ) { - ErrorRecoveryDialog( - error = when ((authState as AuthState.Error).exception) { - is AuthException -> (authState as AuthState.Error).exception as AuthException - else -> AuthException - .from((authState as AuthState.Error).exception) - }, - stringProvider = stringProvider, - onRetry = { exception -> - when (exception) { - is AuthException.InvalidCredentialsException -> state.onSignInClick() - is AuthException.EmailAlreadyInUseException -> state.onGoToSignIn() - } - isErrorDialogVisible.value = false - }, - onDismiss = { - isErrorDialogVisible.value = false - }, - ) - } - if (content != null) { content(state) } else { 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 a025cd1de..970742507 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 @@ -38,7 +38,7 @@ import com.firebase.ui.auth.compose.configuration.auth_provider.verifyPhoneNumbe import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider import com.firebase.ui.auth.compose.data.CountryData import com.firebase.ui.auth.compose.data.CountryUtils -import com.firebase.ui.auth.compose.ui.components.ErrorRecoveryDialog +import com.firebase.ui.auth.compose.ui.components.LocalTopLevelDialogController import com.google.firebase.auth.AuthResult import com.google.firebase.auth.PhoneAuthProvider import kotlinx.coroutines.delay @@ -138,6 +138,7 @@ fun PhoneAuthScreen( val activity = LocalActivity.current val provider = configuration.providers.filterIsInstance().first() val stringProvider = LocalAuthUIStringProvider.current + val dialogController = LocalTopLevelDialogController.current val coroutineScope = rememberCoroutineScope() val step = rememberSaveable { mutableStateOf(PhoneAuthStep.EnterPhoneNumber) } @@ -163,9 +164,6 @@ fun PhoneAuthScreen( val errorMessage = if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null - val isErrorDialogVisible = - remember(authState) { mutableStateOf(authState is AuthState.Error) } - // Handle resend timer countdown LaunchedEffect(resendTimerSeconds.intValue) { if (resendTimerSeconds.intValue > 0) { @@ -205,7 +203,24 @@ fun PhoneAuthScreen( } is AuthState.Error -> { - onError(AuthException.from(state.exception)) + val exception = AuthException.from(state.exception) + onError(exception) + + // Show dialog for phone-specific errors using top-level controller + dialogController?.showErrorDialog( + exception = exception, + onRetry = { ex -> + when (ex) { + is AuthException.InvalidCredentialsException -> { + // User can retry with corrected code or phone number + } + else -> Unit + } + }, + onDismiss = { + // Dialog dismissed + } + ) } is AuthState.Cancelled -> { @@ -288,33 +303,6 @@ fun PhoneAuthScreen( } ) - if (isErrorDialogVisible.value) { - ErrorRecoveryDialog( - error = when ((authState as AuthState.Error).exception) { - is AuthException -> (authState as AuthState.Error).exception as AuthException - else -> AuthException.from((authState as AuthState.Error).exception) - }, - stringProvider = stringProvider, - onRetry = { exception -> - when (exception) { - is AuthException.InvalidCredentialsException -> { - if (step.value == PhoneAuthStep.EnterVerificationCode) { - state.onVerifyCodeClick() - } else { - state.onSendCodeClick() - } - } - - else -> Unit - } - isErrorDialogVisible.value = false - }, - onDismiss = { - isErrorDialogVisible.value = false - }, - ) - } - if (content != null) { content(state) } else { diff --git a/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt index 57deb979a..0992ca793 100644 --- a/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt +++ b/composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt @@ -44,6 +44,10 @@ class HighLevelApiDemoActivity : ComponentActivity() { val configuration = authUIConfiguration { context = applicationContext + logo = AuthUIAsset.Resource(R.drawable.firebase_auth) + tosUrl = "https://policies.google.com/terms" + privacyPolicyUrl = "https://policies.google.com/privacy" + isAnonymousUpgradeEnabled = false providers { provider(AuthProvider.Anonymous) provider( @@ -145,9 +149,6 @@ class HighLevelApiDemoActivity : ComponentActivity() { ) ) } - logo = AuthUIAsset.Resource(R.drawable.firebase_auth) - tosUrl = "https://policies.google.com/terms?hl=en-NG&fg=1" - privacyPolicyUrl = "https://policies.google.com/privacy?hl=en-NG&fg=1" } setContent {