Skip to content

Commit a594050

Browse files
demolafLyokone
andauthored
fix: Introduce dialog controller (#2264)
* fix: introduce dialog controller to avoid multiple dialogs * Update composeapp/src/main/java/com/firebase/composeapp/HighLevelApiDemoActivity.kt Co-authored-by: Guillaume Bernos <guillaume.bernos@gmail.com> * update comments * fix CI --------- Co-authored-by: Guillaume Bernos <guillaume.bernos@gmail.com>
1 parent af52b84 commit a594050

File tree

6 files changed

+251
-95
lines changed

6 files changed

+251
-95
lines changed

auth/src/main/java/com/firebase/ui/auth/compose/AuthException.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,9 @@ abstract class AuthException(
327327
@JvmStatic
328328
fun from(firebaseException: Exception): AuthException {
329329
return when (firebaseException) {
330+
// If already an AuthException, return it directly
331+
is AuthException -> firebaseException
332+
330333
// Handle specific Firebase Auth exceptions first (before general FirebaseException)
331334
is FirebaseAuthInvalidCredentialsException -> {
332335
InvalidCredentialsException(
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* Copyright 2025 Google Inc. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5+
* in compliance with the License. You may obtain a copy of the License at
6+
*
7+
* http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software distributed under the
10+
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
11+
* express or implied. See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
15+
package com.firebase.ui.auth.compose.ui.components
16+
17+
import androidx.compose.runtime.Composable
18+
import androidx.compose.runtime.compositionLocalOf
19+
import androidx.compose.runtime.getValue
20+
import androidx.compose.runtime.mutableStateOf
21+
import androidx.compose.runtime.remember
22+
import androidx.compose.runtime.setValue
23+
import com.firebase.ui.auth.compose.AuthException
24+
import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider
25+
26+
/**
27+
* CompositionLocal for accessing the top-level dialog controller from any composable.
28+
*/
29+
val LocalTopLevelDialogController = compositionLocalOf<TopLevelDialogController?> {
30+
null
31+
}
32+
33+
/**
34+
* A top-level dialog controller that allows any child composable to show error recovery dialogs.
35+
*
36+
* It provides a single point of control for showing dialogs from anywhere in the composition tree,
37+
* preventing duplicate dialogs when multiple screens observe the same error state.
38+
*
39+
* **Usage:**
40+
* ```kotlin
41+
* // At the root of your auth flow (FirebaseAuthScreen):
42+
* val dialogController = rememberTopLevelDialogController(stringProvider)
43+
*
44+
* CompositionLocalProvider(LocalTopLevelDialogController provides dialogController) {
45+
* // Your auth screens...
46+
*
47+
* // Show dialog at root level (only one instance)
48+
* dialogController.CurrentDialog()
49+
* }
50+
*
51+
* // In any child screen (EmailAuthScreen, PhoneAuthScreen, etc.):
52+
* val dialogController = LocalTopLevelDialogController.current
53+
*
54+
* LaunchedEffect(error) {
55+
* error?.let { exception ->
56+
* dialogController?.showErrorDialog(
57+
* exception = exception,
58+
* onRetry = { ... },
59+
* onRecover = { ... },
60+
* onDismiss = { ... }
61+
* )
62+
* }
63+
* }
64+
* ```
65+
*
66+
* @since 10.0.0
67+
*/
68+
class TopLevelDialogController(
69+
private val stringProvider: AuthUIStringProvider
70+
) {
71+
private var dialogState by mutableStateOf<DialogState?>(null)
72+
73+
/**
74+
* Shows an error recovery dialog at the top level using [ErrorRecoveryDialog].
75+
*
76+
* @param exception The auth exception to display
77+
* @param onRetry Callback when user clicks retry button
78+
* @param onRecover Callback when user clicks recover button (e.g., navigate to different screen)
79+
* @param onDismiss Callback when dialog is dismissed
80+
*/
81+
fun showErrorDialog(
82+
exception: AuthException,
83+
onRetry: (AuthException) -> Unit = {},
84+
onRecover: (AuthException) -> Unit = {},
85+
onDismiss: () -> Unit = {}
86+
) {
87+
dialogState = DialogState.ErrorDialog(
88+
exception = exception,
89+
onRetry = onRetry,
90+
onRecover = onRecover,
91+
onDismiss = {
92+
dialogState = null
93+
onDismiss()
94+
}
95+
)
96+
}
97+
98+
/**
99+
* Dismisses the currently shown dialog.
100+
*/
101+
fun dismissDialog() {
102+
dialogState = null
103+
}
104+
105+
/**
106+
* Composable that renders the current dialog, if any.
107+
* This should be called once at the root level of your auth flow.
108+
*
109+
* Uses the existing [ErrorRecoveryDialog] component.
110+
*/
111+
@Composable
112+
fun CurrentDialog() {
113+
val state = dialogState
114+
when (state) {
115+
is DialogState.ErrorDialog -> {
116+
ErrorRecoveryDialog(
117+
error = state.exception,
118+
stringProvider = stringProvider,
119+
onRetry = { exception ->
120+
state.onRetry(exception)
121+
state.onDismiss()
122+
},
123+
onRecover = { exception ->
124+
state.onRecover(exception)
125+
state.onDismiss()
126+
},
127+
onDismiss = state.onDismiss
128+
)
129+
}
130+
null -> {
131+
// No dialog to show
132+
}
133+
}
134+
}
135+
136+
private sealed class DialogState {
137+
data class ErrorDialog(
138+
val exception: AuthException,
139+
val onRetry: (AuthException) -> Unit,
140+
val onRecover: (AuthException) -> Unit,
141+
val onDismiss: () -> Unit
142+
) : DialogState()
143+
}
144+
}
145+
146+
/**
147+
* Creates and remembers a [TopLevelDialogController].
148+
*/
149+
@Composable
150+
fun rememberTopLevelDialogController(
151+
stringProvider: AuthUIStringProvider
152+
): TopLevelDialogController {
153+
return remember(stringProvider) {
154+
TopLevelDialogController(stringProvider)
155+
}
156+
}

auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/FirebaseAuthScreen.kt

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailL
5959
import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider
6060
import com.firebase.ui.auth.compose.configuration.string_provider.DefaultAuthUIStringProvider
6161
import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider
62-
import com.firebase.ui.auth.compose.ui.components.ErrorRecoveryDialog
62+
import com.firebase.ui.auth.compose.ui.components.LocalTopLevelDialogController
63+
import com.firebase.ui.auth.compose.ui.components.rememberTopLevelDialogController
6364
import com.firebase.ui.auth.compose.ui.method_picker.AuthMethodPicker
6465
import com.firebase.ui.auth.compose.ui.screens.phone.PhoneAuthScreen
6566
import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager
@@ -96,9 +97,9 @@ fun FirebaseAuthScreen(
9697
val coroutineScope = rememberCoroutineScope()
9798
val stringProvider = DefaultAuthUIStringProvider(context)
9899
val navController = rememberNavController()
100+
val dialogController = rememberTopLevelDialogController(stringProvider)
99101

100102
val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
101-
val isErrorDialogVisible = remember(authState) { mutableStateOf(authState is AuthState.Error) }
102103
val lastSuccessfulUserId = remember { mutableStateOf<String?>(null) }
103104
val pendingLinkingCredential = remember { mutableStateOf<AuthCredential?>(null) }
104105
val pendingResolver = remember { mutableStateOf<MultiFactorResolver?>(null) }
@@ -192,7 +193,10 @@ fun FirebaseAuthScreen(
192193
)
193194
}
194195

195-
CompositionLocalProvider(LocalAuthUIStringProvider provides configuration.stringProvider) {
196+
CompositionLocalProvider(
197+
LocalAuthUIStringProvider provides configuration.stringProvider,
198+
LocalTopLevelDialogController provides dialogController
199+
) {
196200
Surface(
197201
modifier = Modifier
198202
.fillMaxSize()
@@ -393,7 +397,7 @@ fun FirebaseAuthScreen(
393397
try {
394398
// Try to retrieve saved email from DataStore (same-device flow)
395399
val savedEmail = EmailLinkPersistenceManager.default.retrieveSessionRecord(context)?.email
396-
400+
397401
if (savedEmail != null) {
398402
// Same device - we have the email, sign in automatically
399403
authUI.signInWithEmailLink(
@@ -418,7 +422,7 @@ fun FirebaseAuthScreen(
418422
} catch (e: Exception) {
419423
Log.e("FirebaseAuthScreen", "Failed to complete email link sign-in", e)
420424
}
421-
425+
422426
// Navigate to Email auth screen for cross-device error handling
423427
if (navController.currentBackStackEntry?.destination?.route != AuthRoute.Email.route) {
424428
navController.navigate(AuthRoute.Email.route)
@@ -501,31 +505,30 @@ fun FirebaseAuthScreen(
501505
}
502506
}
503507

508+
// Handle errors using top-level dialog controller
504509
val errorState = authState as? AuthState.Error
505-
if (isErrorDialogVisible.value && errorState != null) {
506-
ErrorRecoveryDialog(
507-
error = when (val throwable = errorState.exception) {
510+
if (errorState != null) {
511+
LaunchedEffect(errorState) {
512+
val exception = when (val throwable = errorState.exception) {
508513
is AuthException -> throwable
509514
else -> AuthException.from(throwable)
510-
},
511-
stringProvider = stringProvider,
512-
onRetry = { exception ->
513-
when (exception) {
514-
is AuthException.InvalidCredentialsException -> Unit
515-
else -> Unit
516-
}
517-
isErrorDialogVisible.value = false
518-
},
519-
onRecover = { exception ->
520-
when (exception) {
521-
is AuthException.EmailAlreadyInUseException -> {
522-
navController.navigate(AuthRoute.Email.route) {
523-
launchSingleTop = true
515+
}
516+
517+
dialogController.showErrorDialog(
518+
exception = exception,
519+
onRetry = { _ ->
520+
// Child screens handle their own retry logic
521+
},
522+
onRecover = { exception ->
523+
when (exception) {
524+
is AuthException.EmailAlreadyInUseException -> {
525+
navController.navigate(AuthRoute.Email.route) {
526+
launchSingleTop = true
527+
}
524528
}
525-
}
526529

527-
is AuthException.AccountLinkingRequiredException -> {
528-
pendingLinkingCredential.value = exception.credential
530+
is AuthException.AccountLinkingRequiredException -> {
531+
pendingLinkingCredential.value = exception.credential
529532
navController.navigate(AuthRoute.Email.route) {
530533
launchSingleTop = true
531534
}
@@ -547,16 +550,19 @@ fun FirebaseAuthScreen(
547550
}
548551
}
549552

550-
else -> Unit
553+
else -> Unit
554+
}
555+
},
556+
onDismiss = {
557+
// Dialog dismissed
551558
}
552-
isErrorDialogVisible.value = false
553-
},
554-
onDismiss = {
555-
isErrorDialogVisible.value = false
556-
}
557-
)
559+
)
560+
}
558561
}
559562

563+
// Render the top-level dialog (only one instance)
564+
dialogController.CurrentDialog()
565+
560566
val loadingState = authState as? AuthState.Loading
561567
if (loadingState != null) {
562568
LoadingDialog(loadingState.message ?: stringProvider.progressDialogLoading)

auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/email/EmailAuthScreen.kt

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import com.firebase.ui.auth.compose.configuration.auth_provider.sendSignInLinkTo
3535
import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailAndPassword
3636
import com.firebase.ui.auth.compose.configuration.auth_provider.signInWithEmailLink
3737
import com.firebase.ui.auth.compose.configuration.string_provider.LocalAuthUIStringProvider
38-
import com.firebase.ui.auth.compose.ui.components.ErrorRecoveryDialog
38+
import com.firebase.ui.auth.compose.ui.components.LocalTopLevelDialogController
3939
import com.google.firebase.auth.AuthCredential
4040
import com.google.firebase.auth.AuthResult
4141
import kotlinx.coroutines.launch
@@ -129,6 +129,7 @@ fun EmailAuthScreen(
129129
) {
130130
val provider = configuration.providers.filterIsInstance<AuthProvider.Email>().first()
131131
val stringProvider = LocalAuthUIStringProvider.current
132+
val dialogController = LocalTopLevelDialogController.current
132133
val coroutineScope = rememberCoroutineScope()
133134

134135
val mode = rememberSaveable { mutableStateOf(EmailAuthMode.SignIn) }
@@ -153,9 +154,6 @@ fun EmailAuthScreen(
153154
val resetLinkSent = authState is AuthState.PasswordResetLinkSent
154155
val emailSignInLinkSent = authState is AuthState.EmailSignInLinkSent
155156

156-
val isErrorDialogVisible =
157-
remember(authState) { mutableStateOf(authState is AuthState.Error) }
158-
159157
LaunchedEffect(authState) {
160158
Log.d("EmailAuthScreen", "Current state: $authState")
161159
when (val state = authState) {
@@ -166,7 +164,34 @@ fun EmailAuthScreen(
166164
}
167165

168166
is AuthState.Error -> {
169-
onError(AuthException.from(state.exception))
167+
val exception = AuthException.from(state.exception)
168+
onError(exception)
169+
170+
// Show dialog for screen-specific errors using top-level controller
171+
// Navigation-related errors are handled by FirebaseAuthScreen
172+
if (exception !is AuthException.AccountLinkingRequiredException &&
173+
exception !is AuthException.EmailLinkPromptForEmailException &&
174+
exception !is AuthException.EmailLinkCrossDeviceLinkingException
175+
) {
176+
dialogController?.showErrorDialog(
177+
exception = exception,
178+
onRetry = { ex ->
179+
when (ex) {
180+
is AuthException.InvalidCredentialsException -> {
181+
// User can retry sign in with corrected credentials
182+
}
183+
is AuthException.EmailAlreadyInUseException -> {
184+
// Switch to sign-in mode
185+
mode.value = EmailAuthMode.SignIn
186+
}
187+
else -> Unit
188+
}
189+
},
190+
onDismiss = {
191+
// Dialog dismissed
192+
}
193+
)
194+
}
170195
}
171196

172197
is AuthState.Cancelled -> {
@@ -280,29 +305,6 @@ fun EmailAuthScreen(
280305
}
281306
)
282307

283-
if (isErrorDialogVisible.value &&
284-
(authState as AuthState.Error).exception !is AuthException.AccountLinkingRequiredException
285-
) {
286-
ErrorRecoveryDialog(
287-
error = when ((authState as AuthState.Error).exception) {
288-
is AuthException -> (authState as AuthState.Error).exception as AuthException
289-
else -> AuthException
290-
.from((authState as AuthState.Error).exception)
291-
},
292-
stringProvider = stringProvider,
293-
onRetry = { exception ->
294-
when (exception) {
295-
is AuthException.InvalidCredentialsException -> state.onSignInClick()
296-
is AuthException.EmailAlreadyInUseException -> state.onGoToSignIn()
297-
}
298-
isErrorDialogVisible.value = false
299-
},
300-
onDismiss = {
301-
isErrorDialogVisible.value = false
302-
},
303-
)
304-
}
305-
306308
if (content != null) {
307309
content(state)
308310
} else {

0 commit comments

Comments
 (0)