diff --git a/auth/src/main/AndroidManifest.xml b/auth/src/main/AndroidManifest.xml
index bb1a19204..a883affca 100644
--- a/auth/src/main/AndroidManifest.xml
+++ b/auth/src/main/AndroidManifest.xml
@@ -83,8 +83,8 @@
@@ -119,6 +119,16 @@
+
+
+
+
+
+
= callbackFlow {
- // Set initial state based on current auth state
- val initialState = auth.currentUser?.let { user ->
- AuthState.Success(result = null, user = user, isNewUser = false)
- } ?: AuthState.Idle
+ fun authStateFlow(): Flow {
+ // Create a flow from FirebaseAuth state listener
+ val firebaseAuthFlow = callbackFlow {
+ // Set initial state based on current auth state
+ val initialState = auth.currentUser?.let { user ->
+ AuthState.Success(result = null, user = user, isNewUser = false)
+ } ?: AuthState.Idle
- trySend(initialState)
+ trySend(initialState)
- // Create auth state listener
- val authStateListener = AuthStateListener { firebaseAuth ->
- val currentUser = firebaseAuth.currentUser
- val state = if (currentUser != null) {
- // Check if email verification is required
- if (!currentUser.isEmailVerified &&
- currentUser.email != null &&
- currentUser.providerData.any { it.providerId == "password" }
- ) {
- AuthState.RequiresEmailVerification(
- user = currentUser,
- email = currentUser.email!!
- )
+ // Create auth state listener
+ val authStateListener = AuthStateListener { firebaseAuth ->
+ val currentUser = firebaseAuth.currentUser
+ val state = if (currentUser != null) {
+ // Check if email verification is required
+ if (!currentUser.isEmailVerified &&
+ currentUser.email != null &&
+ currentUser.providerData.any { it.providerId == "password" }
+ ) {
+ AuthState.RequiresEmailVerification(
+ user = currentUser,
+ email = currentUser.email!!
+ )
+ } else {
+ AuthState.Success(
+ result = null,
+ user = currentUser,
+ isNewUser = false
+ )
+ }
} else {
- AuthState.Success(
- result = null,
- user = currentUser,
- isNewUser = false
- )
+ AuthState.Idle
}
- } else {
- AuthState.Idle
+ trySend(state)
}
- trySend(state)
- }
- // Add listener
- auth.addAuthStateListener(authStateListener)
+ // Add listener
+ auth.addAuthStateListener(authStateListener)
- // Also observe internal state changes
- _authStateFlow.value.let { currentState ->
- if (currentState !is AuthState.Idle && currentState !is AuthState.Success) {
- trySend(currentState)
+ // Remove listener when flow collection is cancelled
+ awaitClose {
+ auth.removeAuthStateListener(authStateListener)
}
}
- // Remove listener when flow collection is cancelled
- awaitClose {
- auth.removeAuthStateListener(authStateListener)
+ // Also observe internal state changes
+ return combine(
+ firebaseAuthFlow,
+ _authStateFlow
+ ) { firebaseState, internalState ->
+ // Prefer non-idle internal states (like PasswordResetLinkSent, Error, etc.)
+ if (internalState !is AuthState.Idle) internalState else firebaseState
}
}
diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt
index 1267aea84..5bbda6cb9 100644
--- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/AuthUIConfiguration.kt
@@ -44,7 +44,7 @@ class AuthUIConfigurationBuilder {
var tosUrl: String? = null
var privacyPolicyUrl: String? = null
var logo: ImageVector? = null
- var actionCodeSettings: ActionCodeSettings? = null
+ var passwordResetActionCodeSettings: ActionCodeSettings? = null
var isNewEmailAccountsAllowed: Boolean = true
var isDisplayNameRequired: Boolean = true
var isProviderChoiceAlwaysShown: Boolean = false
@@ -85,17 +85,7 @@ class AuthUIConfigurationBuilder {
// Provider specific validations
providers.forEach { provider ->
when (provider) {
- is AuthProvider.Email -> {
- provider.validate()
-
- if (isAnonymousUpgradeEnabled && provider.isEmailLinkSignInEnabled) {
- check(provider.isEmailLinkForceSameDeviceEnabled) {
- "You must force the same device flow when using email link sign in " +
- "with anonymous user upgrade"
- }
- }
- }
-
+ is AuthProvider.Email -> provider.validate(isAnonymousUpgradeEnabled)
is AuthProvider.Phone -> provider.validate()
is AuthProvider.Google -> provider.validate(context)
is AuthProvider.Facebook -> provider.validate(context)
@@ -116,7 +106,7 @@ class AuthUIConfigurationBuilder {
tosUrl = tosUrl,
privacyPolicyUrl = privacyPolicyUrl,
logo = logo,
- actionCodeSettings = actionCodeSettings,
+ passwordResetActionCodeSettings = passwordResetActionCodeSettings,
isNewEmailAccountsAllowed = isNewEmailAccountsAllowed,
isDisplayNameRequired = isDisplayNameRequired,
isProviderChoiceAlwaysShown = isProviderChoiceAlwaysShown
@@ -184,9 +174,9 @@ class AuthUIConfiguration(
val logo: ImageVector? = null,
/**
- * Configuration for email link sign-in.
+ * Configuration for sending email reset link.
*/
- val actionCodeSettings: ActionCodeSettings? = null,
+ val passwordResetActionCodeSettings: ActionCodeSettings? = null,
/**
* Allows new email accounts to be created. Defaults to true.
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 1c9b88048..bb5f2b9b3 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
@@ -174,7 +174,7 @@ abstract class AuthProvider(open val providerId: String) {
/**
* Settings for email link actions.
*/
- val actionCodeSettings: ActionCodeSettings?,
+ val emailLinkActionCodeSettings: ActionCodeSettings?,
/**
* Allows new accounts to be created. Defaults to true.
@@ -202,9 +202,11 @@ abstract class AuthProvider(open val providerId: String) {
val KEY_IDP_SECRET = stringPreferencesKey("com.firebase.ui.auth.data.client.idpSecret")
}
- internal fun validate() {
+ internal fun validate(
+ isAnonymousUpgradeEnabled: Boolean = false
+ ) {
if (isEmailLinkSignInEnabled) {
- val actionCodeSettings = requireNotNull(actionCodeSettings) {
+ val actionCodeSettings = requireNotNull(emailLinkActionCodeSettings) {
"ActionCodeSettings cannot be null when using " +
"email link sign in."
}
@@ -213,6 +215,13 @@ abstract class AuthProvider(open val providerId: String) {
"You must set canHandleCodeInApp in your " +
"ActionCodeSettings to true for Email-Link Sign-in."
}
+
+ if (isAnonymousUpgradeEnabled) {
+ check(isEmailLinkForceSameDeviceEnabled) {
+ "You must force the same device flow when using email link sign in " +
+ "with anonymous user upgrade"
+ }
+ }
}
}
@@ -221,11 +230,11 @@ abstract class AuthProvider(open val providerId: String) {
sessionId: String,
anonymousUserId: String,
): ActionCodeSettings {
- requireNotNull(actionCodeSettings) {
+ requireNotNull(emailLinkActionCodeSettings) {
"ActionCodeSettings is required for email link sign in"
}
- val continueUrl = continueUrl(actionCodeSettings.url) {
+ val continueUrl = continueUrl(emailLinkActionCodeSettings.url) {
appendSessionId(sessionId)
appendAnonymousUserId(anonymousUserId)
appendForceSameDeviceBit(isEmailLinkForceSameDeviceEnabled)
@@ -234,12 +243,12 @@ abstract class AuthProvider(open val providerId: String) {
return actionCodeSettings {
url = continueUrl
- handleCodeInApp = actionCodeSettings.canHandleCodeInApp()
- iosBundleId = actionCodeSettings.iosBundle
+ handleCodeInApp = emailLinkActionCodeSettings.canHandleCodeInApp()
+ iosBundleId = emailLinkActionCodeSettings.iosBundle
setAndroidPackageName(
- actionCodeSettings.androidPackageName ?: "",
- actionCodeSettings.androidInstallApp,
- actionCodeSettings.androidMinimumVersion
+ emailLinkActionCodeSettings.androidPackageName ?: "",
+ emailLinkActionCodeSettings.androidInstallApp,
+ emailLinkActionCodeSettings.androidMinimumVersion
)
}
}
diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt
index d917e80d6..a65fcac8a 100644
--- a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProvider+FirebaseAuthUI.kt
@@ -1,3 +1,17 @@
+/*
+ * 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.configuration.auth_provider
import android.content.Context
@@ -661,7 +675,7 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail(
// Save Email to dataStore for use in signInWithEmailLink
EmailLinkPersistenceManager.saveEmail(context, email, sessionId, anonymousUserId)
- updateAuthState(AuthState.Idle)
+ updateAuthState(AuthState.EmailSignInLinkSent())
} catch (e: CancellationException) {
val cancelledException = AuthException.AuthCancelledException(
message = "Send sign in link to email was cancelled",
@@ -713,7 +727,8 @@ internal suspend fun FirebaseAuthUI.sendSignInLinkToEmail(
*
* @see sendSignInLinkToEmail for sending the initial email link
*/
-internal suspend fun FirebaseAuthUI.signInWithEmailLink(
+// TODO(demolaf: make this internal when done testing email link sign in with composeapp
+suspend fun FirebaseAuthUI.signInWithEmailLink(
context: Context,
config: AuthUIConfiguration,
provider: AuthProvider.Email,
@@ -778,7 +793,10 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink(
// Validate anonymous user ID matches (same-device flow)
if (!anonymousUserIdFromLink.isNullOrEmpty()) {
val currentUser = auth.currentUser
- if (currentUser == null || !currentUser.isAnonymous || currentUser.uid != anonymousUserIdFromLink) {
+ if (currentUser == null
+ || !currentUser.isAnonymous
+ || currentUser.uid != anonymousUserIdFromLink
+ ) {
throw AuthException.EmailLinkDifferentAnonymousUserException()
}
}
@@ -790,7 +808,6 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink(
val result = if (storedCredentialForLink == null) {
// Normal Flow: Just sign in with email link
signInAndLinkWithCredential(config, emailLinkCredential)
- ?: throw AuthException.UnknownException("Sign in failed")
} else {
// Linking Flow: Sign in with email link, then link the social credential
if (canUpgradeAnonymous(config, auth)) {
@@ -804,54 +821,39 @@ internal suspend fun FirebaseAuthUI.signInWithEmailLink(
.getInstance(appExplicitlyForValidation)
// Safe link: Validate that both credentials can be linked
- val emailResult = authExplicitlyForValidation
+ val result = authExplicitlyForValidation
.signInWithCredential(emailLinkCredential).await()
-
- val linkResult = emailResult.user
- ?.linkWithCredential(storedCredentialForLink)?.await()
-
- // If safe link succeeds, emit merge conflict for UI to handle
- if (linkResult?.user != null) {
- updateAuthState(
- AuthState.MergeConflict(
- storedCredentialForLink
+ .user?.linkWithCredential(storedCredentialForLink)?.await()
+ .also { result ->
+ // If safe link succeeds, emit merge conflict for UI to handle
+ updateAuthState(
+ AuthState.MergeConflict(
+ storedCredentialForLink
+ )
)
- )
- }
-
- // Return the link result (will be non-null if successful)
- linkResult
+ }
+ return result
} else {
// Non-upgrade: Sign in with email link, then link social credential
- val emailLinkResult = auth.signInWithCredential(emailLinkCredential).await()
-
- // Link the social credential
- val linkResult = emailLinkResult.user
- ?.linkWithCredential(storedCredentialForLink)?.await()
-
- // Merge profile from the linked social credential
- linkResult?.user?.let { user ->
- mergeProfile(auth, user.displayName, user.photoUrl)
- }
-
- // Update to success state
- if (linkResult?.user != null) {
- updateAuthState(
- AuthState.Success(
- result = linkResult,
- user = linkResult.user!!,
- isNewUser = linkResult.additionalUserInfo?.isNewUser ?: false
- )
- )
- }
-
- linkResult
+ val result = auth.signInWithCredential(emailLinkCredential).await()
+ // Link the social credential
+ .user?.linkWithCredential(storedCredentialForLink)?.await()
+ .also { result ->
+ result?.user?.let { user ->
+ // Merge profile from the linked social credential
+ mergeProfile(
+ auth,
+ user.displayName,
+ user.photoUrl
+ )
+ }
+ }
+ return result
}
}
-
// Clear DataStore after success
EmailLinkPersistenceManager.clear(context)
-
+ updateAuthState(AuthState.Idle)
return result
} catch (e: CancellationException) {
val cancelledException = AuthException.AuthCancelledException(
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 4af62ffc8..122da349e 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
@@ -14,11 +14,15 @@
package com.firebase.ui.auth.compose.configuration.theme
+import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
+import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.Typography
+import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
@@ -106,9 +110,14 @@ class AuthUITheme(
* pre-configured provider styles.
*/
val Default = AuthUITheme(
- colorScheme = lightColorScheme(
- primary = Color(0xFFFFA611)
- ),
+ colorScheme = lightColorScheme(),
+ typography = Typography(),
+ shapes = Shapes(),
+ providerStyles = ProviderStyleDefaults.default
+ )
+
+ val DefaultDark = AuthUITheme(
+ colorScheme = darkColorScheme(),
typography = Typography(),
shapes = Shapes(),
providerStyles = ProviderStyleDefaults.default
@@ -129,12 +138,21 @@ class AuthUITheme(
providerStyles = providerStyles
)
}
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @get:Composable
+ val topAppBarColors
+ get() = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.primary,
+ titleContentColor = MaterialTheme.colorScheme.onPrimary
+ )
}
}
@Composable
fun AuthUITheme(
- theme: AuthUITheme = AuthUITheme.Default,
+ theme: AuthUITheme = if (isSystemInDarkTheme())
+ AuthUITheme.DefaultDark else AuthUITheme.Default,
content: @Composable () -> Unit
) {
MaterialTheme(
diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/GeneralFieldValidator.kt b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/GeneralFieldValidator.kt
new file mode 100644
index 000000000..74a08fe68
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/compose/configuration/validators/GeneralFieldValidator.kt
@@ -0,0 +1,52 @@
+/*
+ * 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.configuration.validators
+
+import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringProvider
+
+internal class GeneralFieldValidator(
+ override val stringProvider: AuthUIStringProvider,
+ val isValid: ((String) -> Boolean)? = null,
+ val customMessage: String? = null,
+) : FieldValidator {
+ private var _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+
+ override val hasError: Boolean
+ get() = _validationStatus.hasError
+
+ override val errorMessage: String
+ get() = _validationStatus.errorMessage ?: ""
+
+ override fun validate(value: String): Boolean {
+ if (value.isEmpty()) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = stringProvider.requiredField
+ )
+ return false
+ }
+
+ if (isValid != null && !isValid(value)) {
+ _validationStatus = FieldValidationStatus(
+ hasError = true,
+ errorMessage = customMessage
+ )
+ return false
+ }
+
+ _validationStatus = FieldValidationStatus(hasError = false, errorMessage = null)
+ return true
+ }
+}
\ No newline at end of file
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 8bed40873..fb6b9098f 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
@@ -1,3 +1,17 @@
+/*
+ * 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.foundation.Image
@@ -164,7 +178,7 @@ private fun PreviewAuthProviderButton() {
) {
AuthProviderButton(
provider = AuthProvider.Email(
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = emptyList()
),
onClick = {},
diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt
index 66f2b475e..200dd0ece 100644
--- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/AuthTextField.kt
@@ -1,10 +1,26 @@
+/*
+ * 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.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
@@ -75,6 +91,7 @@ fun AuthTextField(
value: String,
onValueChange: (String) -> Unit,
label: @Composable (() -> Unit)? = null,
+ isSecureTextField: Boolean = false,
enabled: Boolean = true,
isError: Boolean? = null,
errorMessage: String? = null,
@@ -85,11 +102,11 @@ fun AuthTextField(
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
) {
- val isSecureTextField = validator is PasswordValidator
var passwordVisible by remember { mutableStateOf(false) }
TextField(
- modifier = modifier,
+ modifier = modifier
+ .fillMaxWidth(),
value = value,
onValueChange = { newValue ->
onValueChange(newValue)
@@ -150,7 +167,8 @@ internal fun PreviewAuthTextField() {
Column(
modifier = Modifier
- .fillMaxSize(),
+ .fillMaxSize()
+ .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
@@ -184,6 +202,7 @@ internal fun PreviewAuthTextField() {
AuthTextField(
value = passwordTextValue.value,
validator = passwordValidator,
+ isSecureTextField = true,
label = {
Text("Password")
},
diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt
index 6698adc67..42f7339a9 100644
--- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/ErrorRecoveryDialog.kt
@@ -65,7 +65,7 @@ import com.firebase.ui.auth.compose.configuration.string_provider.AuthUIStringPr
fun ErrorRecoveryDialog(
error: AuthException,
stringProvider: AuthUIStringProvider,
- onRetry: () -> Unit,
+ onRetry: (AuthException) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
onRecover: ((AuthException) -> Unit)? = null,
@@ -90,7 +90,7 @@ fun ErrorRecoveryDialog(
if (isRecoverable(error)) {
TextButton(
onClick = {
- onRecover?.invoke(error) ?: onRetry()
+ onRecover?.invoke(error) ?: onRetry(error)
}
) {
Text(
diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/TermsAndPrivacyForm.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/TermsAndPrivacyForm.kt
new file mode 100644
index 000000000..82694d3dc
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/components/TermsAndPrivacyForm.kt
@@ -0,0 +1,73 @@
+/*
+ * 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.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.width
+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.platform.LocalUriHandler
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun TermsAndPrivacyForm(
+ modifier: Modifier = Modifier,
+ tosUrl: String?,
+ ppUrl: String?
+) {
+ val uriHandler = LocalUriHandler.current
+ Row(
+ modifier = modifier,
+ ) {
+ TextButton(
+ onClick = {
+ tosUrl?.let {
+ uriHandler.openUri(it)
+ }
+ },
+ contentPadding = PaddingValues.Zero,
+ ) {
+ Text(
+ text = "Terms of Service",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ textDecoration = TextDecoration.Underline
+ )
+ }
+ Spacer(modifier = Modifier.width(24.dp))
+ TextButton(
+ onClick = {
+ ppUrl?.let {
+ uriHandler.openUri(it)
+ }
+ },
+ contentPadding = PaddingValues.Zero,
+ ) {
+ Text(
+ text = "Privacy Policy",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ textDecoration = TextDecoration.Underline
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AnnotatedStringResource.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AnnotatedStringResource.kt
index 4c98be9ac..0c16fb31d 100644
--- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AnnotatedStringResource.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AnnotatedStringResource.kt
@@ -1,3 +1,17 @@
+/*
+ * 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.method_picker
import android.content.Context
diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt
index 58466f37e..6082e9f1b 100644
--- a/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt
+++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPicker.kt
@@ -1,3 +1,17 @@
+/*
+ * 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.method_picker
import androidx.compose.foundation.Image
@@ -131,7 +145,7 @@ fun PreviewAuthMethodPicker() {
AuthMethodPicker(
providers = listOf(
AuthProvider.Email(
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = emptyList()
),
AuthProvider.Phone(
diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreen.kt
new file mode 100644
index 000000000..4fbebd2bf
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailAuthScreen.kt
@@ -0,0 +1,288 @@
+/*
+ * 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 android.content.Context
+import android.util.Log
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import com.firebase.ui.auth.compose.AuthException
+import com.firebase.ui.auth.compose.AuthState
+import com.firebase.ui.auth.compose.FirebaseAuthUI
+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.auth_provider.createOrLinkUserWithEmailAndPassword
+import com.firebase.ui.auth.compose.configuration.auth_provider.sendPasswordResetEmail
+import com.firebase.ui.auth.compose.configuration.auth_provider.sendSignInLinkToEmail
+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.DefaultAuthUIStringProvider
+import com.firebase.ui.auth.compose.ui.components.ErrorRecoveryDialog
+import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager
+import com.google.firebase.auth.AuthResult
+import kotlinx.coroutines.launch
+
+enum class EmailAuthMode {
+ SignIn,
+ SignUp,
+ ResetPassword,
+}
+
+/**
+ * A class passed to the content slot, containing all the necessary information to render custom
+ * UIs for sign-in, sign-up, and password reset flows.
+ *
+ * @param mode An enum representing the current UI mode. Use a when expression on this to render
+ * the correct screen.
+ * @param isLoading true when an asynchronous operation (like signing in or sending an email)
+ * is in progress.
+ * @param error An optional error message to display to the user.
+ * @param email The current value of the email input field.
+ * @param onEmailChange (Modes: [EmailAuthMode.SignIn], [EmailAuthMode.SignUp],
+ * [EmailAuthMode.ResetPassword]) A callback to be invoked when the email input changes.
+ * @param password An optional custom layout composable for the provider buttons.
+ * @param onPasswordChange (Modes: [EmailAuthMode.SignIn], [EmailAuthMode.SignUp]) The current
+ * value of the password input field.
+ * @param confirmPassword (Mode: [EmailAuthMode.SignUp]) A callback to be invoked when the password
+ * input changes.
+ * @param onConfirmPasswordChange (Mode: [EmailAuthMode.SignUp]) A callback to be invoked when
+ * the password confirmation input changes.
+ * @param displayName (Mode: [EmailAuthMode.SignUp]) The current value of the display name field.
+ * @param onDisplayNameChange (Mode: [EmailAuthMode.SignUp]) A callback to be invoked when the
+ * display name input changes.
+ * @param onSignInClick (Mode: [EmailAuthMode.SignIn]) A callback to be invoked to attempt a
+ * sign-in with the provided credentials.
+ * @param onSignUpClick (Mode: [EmailAuthMode.SignUp]) A callback to be invoked to attempt to
+ * create a new account.
+ * @param onSendResetLinkClick (Mode: [EmailAuthMode.ResetPassword]) A callback to be invoked to
+ * send a password reset email.
+ * @param resetLinkSent (Mode: [EmailAuthMode.ResetPassword]) true after the password reset link
+ * has been successfully sent.
+ * @param emailSignInLinkSent (Mode: [EmailAuthMode.SignIn]) true after the email sign in link has
+ * been successfully sent.
+ * @param onGoToSignUp A callback to switch the UI to the SignUp mode.
+ * @param onGoToSignIn A callback to switch the UI to the SignIn mode.
+ * @param onGoToResetPassword A callback to switch the UI to the ResetPassword mode.
+ */
+class EmailAuthContentState(
+ val mode: EmailAuthMode,
+ val isLoading: Boolean = false,
+ val error: String? = null,
+ val email: String,
+ val onEmailChange: (String) -> Unit,
+ val password: String,
+ val onPasswordChange: (String) -> Unit,
+ val confirmPassword: String,
+ val onConfirmPasswordChange: (String) -> Unit,
+ val displayName: String,
+ val onDisplayNameChange: (String) -> Unit,
+ val onSignInClick: () -> Unit,
+ val onSignUpClick: () -> Unit,
+ val onSendResetLinkClick: () -> Unit,
+ val resetLinkSent: Boolean = false,
+ val emailSignInLinkSent: Boolean = false,
+ val onGoToSignUp: () -> Unit,
+ val onGoToSignIn: () -> Unit,
+ val onGoToResetPassword: () -> Unit,
+)
+
+/**
+ * A stateful composable that manages the logic for all email-based authentication flows,
+ * including sign-in, sign-up, and password reset. It exposes the state for the current mode to
+ * a custom UI via a trailing lambda (slot), allowing for complete visual customization.
+ *
+ * @param configuration
+ * @param onSuccess
+ * @param onError
+ * @param onCancel
+ * @param content
+ */
+@Composable
+fun EmailAuthScreen(
+ context: Context,
+ configuration: AuthUIConfiguration,
+ authUI: FirebaseAuthUI,
+ onSuccess: (AuthResult) -> Unit,
+ onError: (AuthException) -> Unit,
+ onCancel: () -> Unit,
+ content: @Composable ((EmailAuthContentState) -> Unit)? = null,
+) {
+ val provider = configuration.providers.filterIsInstance().first()
+ val stringProvider = DefaultAuthUIStringProvider(context)
+ val coroutineScope = rememberCoroutineScope()
+
+ val mode = rememberSaveable { mutableStateOf(EmailAuthMode.SignIn) }
+ val displayNameValue = rememberSaveable { mutableStateOf("") }
+ val emailTextValue = rememberSaveable { mutableStateOf("") }
+ val passwordTextValue = rememberSaveable { mutableStateOf("") }
+ val confirmPasswordTextValue = rememberSaveable { mutableStateOf("") }
+
+ // Used for clearing text fields when switching EmailAuthMode changes
+ val textValues = listOf(
+ displayNameValue,
+ emailTextValue,
+ passwordTextValue,
+ confirmPasswordTextValue
+ )
+
+ val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
+ val isLoading = authState is AuthState.Loading
+ val errorMessage =
+ if (authState is AuthState.Error) (authState as AuthState.Error).exception.message else null
+ 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) {
+ is AuthState.Success -> {
+ state.result?.let { result ->
+ onSuccess(result)
+ }
+ }
+
+ is AuthState.Error -> {
+ onError(AuthException.from(state.exception))
+ }
+
+ is AuthState.Cancelled -> {
+ onCancel()
+ }
+
+ else -> Unit
+ }
+ }
+
+ val state = EmailAuthContentState(
+ mode = mode.value,
+ displayName = displayNameValue.value,
+ email = emailTextValue.value,
+ password = passwordTextValue.value,
+ confirmPassword = confirmPasswordTextValue.value,
+ isLoading = isLoading,
+ error = errorMessage,
+ resetLinkSent = resetLinkSent,
+ emailSignInLinkSent = emailSignInLinkSent,
+ onEmailChange = { email ->
+ emailTextValue.value = email
+ },
+ onPasswordChange = { password ->
+ passwordTextValue.value = password
+ },
+ onConfirmPasswordChange = { confirmPassword ->
+ confirmPasswordTextValue.value = confirmPassword
+ },
+ onDisplayNameChange = { displayName ->
+ displayNameValue.value = displayName
+ },
+ onSignInClick = {
+ coroutineScope.launch {
+ try {
+ if (provider.isEmailLinkSignInEnabled) {
+ authUI.sendSignInLinkToEmail(
+ context = context,
+ config = configuration,
+ provider = provider,
+ email = emailTextValue.value,
+ credentialForLinking = null,
+ )
+ } else {
+ authUI.signInWithEmailAndPassword(
+ context = context,
+ config = configuration,
+ email = emailTextValue.value,
+ password = passwordTextValue.value,
+ credentialForLinking = null,
+ )
+ }
+ } catch (e: Exception) {
+
+ }
+ }
+ },
+ onSignUpClick = {
+ coroutineScope.launch {
+ try {
+ authUI.createOrLinkUserWithEmailAndPassword(
+ context = context,
+ config = configuration,
+ provider = provider,
+ name = displayNameValue.value,
+ email = emailTextValue.value,
+ password = passwordTextValue.value,
+ )
+ } catch (e: Exception) {
+
+ }
+ }
+ },
+ onSendResetLinkClick = {
+ coroutineScope.launch {
+ try {
+ authUI.sendPasswordResetEmail(
+ email = emailTextValue.value,
+ actionCodeSettings = configuration.passwordResetActionCodeSettings,
+ )
+ } catch (e: Exception) {
+
+ }
+ }
+ },
+ onGoToSignUp = {
+ textValues.forEach { it.value = "" }
+ mode.value = EmailAuthMode.SignUp
+ },
+ onGoToSignIn = {
+ textValues.forEach { it.value = "" }
+ mode.value = EmailAuthMode.SignIn
+ },
+ onGoToResetPassword = {
+ textValues.forEach { it.value = "" }
+ mode.value = EmailAuthMode.ResetPassword
+ }
+ )
+
+ 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 -> state.onSignInClick()
+ is AuthException.EmailAlreadyInUseException -> state.onGoToSignIn()
+ }
+ isErrorDialogVisible.value = false
+ },
+ onDismiss = {
+ isErrorDialogVisible.value = false
+ },
+ )
+ }
+
+ content?.invoke(state)
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailSignInLinkHandlerActivity.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailSignInLinkHandlerActivity.kt
new file mode 100644
index 000000000..e6e8ca7dd
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/EmailSignInLinkHandlerActivity.kt
@@ -0,0 +1,109 @@
+/*
+ * 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 android.content.Intent
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import com.firebase.ui.auth.compose.FirebaseAuthUI
+
+/**
+ * Activity that handles email link deep links for passwordless authentication.
+ *
+ * ## Setup (Required)
+ *
+ * Add this activity to your app's `AndroidManifest.xml`:
+ * ```xml
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * ```
+ *
+ * Configure matching ActionCodeSettings:
+ * ```kotlin
+ * val provider = AuthProvider.Email(
+ * emailLinkActionCodeSettings = actionCodeSettings {
+ * url = "https://yourapp.com" // Must match android:host above
+ * handleCodeInApp = true
+ * setAndroidPackageName("com.yourapp.package", true, null)
+ * },
+ * isEmailLinkSignInEnabled = true
+ * )
+ * ```
+ *
+ * By default, users see a dialog "Open with Browser or App?" on first click.
+ * For auto-opening without dialog, set up App Links verification:
+ * https://developer.android.com/training/app-links/verify-android-applinks
+ *
+ * @see FirebaseAuthUI.sendSignInLinkToEmail
+ */
+class EmailSignInLinkHandlerActivity : ComponentActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Extract email link from deep link intent
+ val emailLink = intent.data?.toString()
+
+ if (emailLink.isNullOrEmpty()) {
+ // No valid email link, just finish
+ finish()
+ return
+ }
+
+ // Redirect to app's launch activity with the email link
+ // The app should check for this extra in onCreate and handle email link sign-in
+ val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
+
+ if (launchIntent != null) {
+ launchIntent.apply {
+ // Clear the back stack and start fresh
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ // Pass the email link to the launch activity
+ putExtra(EXTRA_EMAIL_LINK, emailLink)
+ }
+ startActivity(launchIntent)
+ }
+
+ finish()
+ }
+
+ companion object {
+ /**
+ * Intent extra key for the email link.
+ *
+ * Check for this extra in your MainActivity's onCreate to detect email link sign-in:
+ * ```kotlin
+ * val emailLink = intent.getStringExtra(EmailSignInLinkHandlerActivity.EXTRA_EMAIL_LINK)
+ * if (emailLink != null) {
+ * // Handle email link sign-in
+ * firebaseAuthUI.signInWithEmailLink(...)
+ * }
+ * ```
+ */
+ const val EXTRA_EMAIL_LINK = "com.firebase.ui.auth.EXTRA_EMAIL_LINK"
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/ResetPasswordUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/ResetPasswordUI.kt
new file mode 100644
index 000000000..79177c085
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/ResetPasswordUI.kt
@@ -0,0 +1,216 @@
+/*
+ * 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.Row
+import androidx.compose.foundation.layout.Spacer
+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.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Email
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+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.material3.TopAppBarDefaults
+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.text.style.TextAlign
+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.DefaultAuthUIStringProvider
+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
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ResetPasswordUI(
+ modifier: Modifier = Modifier,
+ configuration: AuthUIConfiguration,
+ isLoading: Boolean,
+ email: String,
+ resetLinkSent: Boolean,
+ onEmailChange: (String) -> Unit,
+ onSendResetLink: () -> Unit,
+ onGoToSignIn: () -> Unit,
+) {
+
+ val context = LocalContext.current
+ val stringProvider = DefaultAuthUIStringProvider(context)
+ val emailValidator = remember {
+ EmailValidator(stringProvider)
+ }
+
+ val isFormValid = remember(email) {
+ derivedStateOf { emailValidator.validate(email) }
+ }
+
+ val isDialogVisible = remember(resetLinkSent) { mutableStateOf(resetLinkSent) }
+
+ if (isDialogVisible.value) {
+ AlertDialog(
+ title = {
+ Text(
+ text = "Reset Link Sent",
+ style = MaterialTheme.typography.headlineSmall
+ )
+ },
+ text = {
+ Text(
+ text = "Check your email $email",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Start
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ isDialogVisible.value = false
+ }
+ ) {
+ Text("Dismiss")
+ }
+ },
+ onDismissRequest = {
+ isDialogVisible.value = false
+ },
+ )
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text("Recover Password")
+ },
+ colors = AuthUITheme.topAppBarColors
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .safeDrawingPadding()
+ .padding(horizontal = 16.dp),
+ ) {
+ AuthTextField(
+ value = email,
+ validator = emailValidator,
+ enabled = !isLoading,
+ label = {
+ Text(stringProvider.emailHint)
+ },
+ onValueChange = { text ->
+ onEmailChange(text)
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Email,
+ contentDescription = ""
+ )
+ }
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier
+ .align(Alignment.End),
+ ) {
+ Button(
+ onClick = {
+ onGoToSignIn()
+ },
+ enabled = !isLoading,
+ ) {
+ Text("Sign In")
+ }
+ Spacer(modifier = Modifier.width(16.dp))
+ Button(
+ onClick = {
+ onSendResetLink()
+ },
+ enabled = !isLoading && isFormValid.value,
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(16.dp)
+ )
+ } else {
+ Text("Send")
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ TermsAndPrivacyForm(
+ modifier = Modifier.align(Alignment.End),
+ tosUrl = configuration.tosUrl,
+ ppUrl = configuration.privacyPolicyUrl,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewResetPasswordUI() {
+ val applicationContext = LocalContext.current
+ val provider = AuthProvider.Email(
+ isDisplayNameRequired = true,
+ isEmailLinkSignInEnabled = false,
+ isEmailLinkForceSameDeviceEnabled = true,
+ emailLinkActionCodeSettings = null,
+ isNewAccountsAllowed = true,
+ minimumPasswordLength = 8,
+ passwordValidationRules = listOf()
+ )
+
+ AuthUITheme {
+ ResetPasswordUI(
+ configuration = authUIConfiguration {
+ context = applicationContext
+ providers { provider(provider) }
+ tosUrl = ""
+ privacyPolicyUrl = ""
+ },
+ email = "",
+ isLoading = false,
+ resetLinkSent = true,
+ onEmailChange = { email -> },
+ onSendResetLink = {},
+ onGoToSignIn = {},
+ )
+ }
+}
\ No newline at end of file
diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/SignInUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/SignInUI.kt
new file mode 100644
index 000000000..edc7ffa9e
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/SignInUI.kt
@@ -0,0 +1,278 @@
+/*
+ * 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.height
+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.material.icons.Icons
+import androidx.compose.material.icons.filled.Email
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+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.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.DefaultAuthUIStringProvider
+import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme
+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
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SignInUI(
+ modifier: Modifier = Modifier,
+ configuration: AuthUIConfiguration,
+ isLoading: Boolean,
+ emailSignInLinkSent: Boolean,
+ email: String,
+ password: String,
+ onEmailChange: (String) -> Unit,
+ onPasswordChange: (String) -> Unit,
+ onSignInClick: () -> Unit,
+ onGoToSignUp: () -> Unit,
+ onGoToResetPassword: () -> Unit,
+) {
+ val context = LocalContext.current
+ val provider = configuration.providers.filterIsInstance().first()
+ val stringProvider = DefaultAuthUIStringProvider(context)
+ val emailValidator = remember { EmailValidator(stringProvider) }
+ val passwordValidator = remember {
+ PasswordValidator(stringProvider = stringProvider, rules = emptyList())
+ }
+
+ val isFormValid = remember(email, password) {
+ derivedStateOf {
+ listOf(
+ emailValidator.validate(email),
+ if (!provider.isEmailLinkSignInEnabled)
+ passwordValidator.validate(password) else true,
+ ).all { it }
+ }
+ }
+
+ val isDialogVisible =
+ remember(emailSignInLinkSent) { mutableStateOf(emailSignInLinkSent) }
+
+ if (isDialogVisible.value) {
+ AlertDialog(
+ title = {
+ Text(
+ text = "Email Sign In Link Sent",
+ style = MaterialTheme.typography.headlineSmall
+ )
+ },
+ text = {
+ Text(
+ text = "Check your email $email",
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Start
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ isDialogVisible.value = false
+ }
+ ) {
+ Text("Dismiss")
+ }
+ },
+ onDismissRequest = {
+ isDialogVisible.value = false
+ },
+ )
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(stringProvider.signInDefault)
+ },
+ colors = AuthUITheme.topAppBarColors
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .safeDrawingPadding()
+ .padding(horizontal = 16.dp),
+ ) {
+ AuthTextField(
+ value = email,
+ validator = emailValidator,
+ enabled = !isLoading,
+ label = {
+ Text(stringProvider.emailHint)
+ },
+ onValueChange = { text ->
+ onEmailChange(text)
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Email,
+ contentDescription = ""
+ )
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ if (!provider.isEmailLinkSignInEnabled) {
+ AuthTextField(
+ value = password,
+ validator = passwordValidator,
+ enabled = !isLoading,
+ isSecureTextField = true,
+ label = {
+ Text(stringProvider.passwordHint)
+ },
+ onValueChange = { text ->
+ onPasswordChange(text)
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Lock,
+ contentDescription = ""
+ )
+ }
+ )
+ 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 (!provider.isEmailLinkSignInEnabled) {
+ Button(
+ onClick = {
+ onGoToSignUp()
+ },
+ enabled = !isLoading,
+ ) {
+ Text(stringProvider.titleRegisterEmail)
+ }
+ 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
+ onSignInClick()
+ },
+ enabled = !isLoading && isFormValid.value,
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(16.dp)
+ )
+ } else {
+ Text(stringProvider.signInDefault)
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ TermsAndPrivacyForm(
+ modifier = Modifier.align(Alignment.End),
+ tosUrl = configuration.tosUrl,
+ ppUrl = configuration.privacyPolicyUrl,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewSignInUI() {
+ val applicationContext = LocalContext.current
+ val provider = AuthProvider.Email(
+ isDisplayNameRequired = true,
+ isEmailLinkSignInEnabled = false,
+ isEmailLinkForceSameDeviceEnabled = true,
+ emailLinkActionCodeSettings = null,
+ isNewAccountsAllowed = true,
+ minimumPasswordLength = 8,
+ passwordValidationRules = listOf()
+ )
+
+ AuthUITheme {
+ SignInUI(
+ configuration = authUIConfiguration {
+ context = applicationContext
+ providers { provider(provider) }
+ tosUrl = ""
+ privacyPolicyUrl = ""
+ },
+ email = "",
+ password = "",
+ isLoading = false,
+ emailSignInLinkSent = false,
+ onEmailChange = { email -> },
+ onPasswordChange = { password -> },
+ onSignInClick = {},
+ onGoToSignUp = {},
+ onGoToResetPassword = {},
+ )
+ }
+}
diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/SignUpUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/SignUpUI.kt
new file mode 100644
index 000000000..9d138e8a8
--- /dev/null
+++ b/auth/src/main/java/com/firebase/ui/auth/compose/ui/screens/SignUpUI.kt
@@ -0,0 +1,273 @@
+/*
+ * 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.Row
+import androidx.compose.foundation.layout.Spacer
+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.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AccountCircle
+import androidx.compose.material.icons.filled.Email
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+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.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.DefaultAuthUIStringProvider
+import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.compose.configuration.validators.GeneralFieldValidator
+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
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SignUpUI(
+ modifier: Modifier = Modifier,
+ configuration: AuthUIConfiguration,
+ isLoading: Boolean,
+ displayName: String,
+ email: String,
+ password: String,
+ confirmPassword: String,
+ onDisplayNameChange: (String) -> Unit,
+ onEmailChange: (String) -> Unit,
+ onPasswordChange: (String) -> Unit,
+ onConfirmPasswordChange: (String) -> Unit,
+ onGoToSignIn: () -> Unit,
+ onSignUpClick: () -> Unit,
+) {
+ val provider = configuration.providers.filterIsInstance().first()
+ val context = LocalContext.current
+ val stringProvider = DefaultAuthUIStringProvider(context)
+ val displayNameValidator = remember { GeneralFieldValidator(stringProvider) }
+ val emailValidator = remember { EmailValidator(stringProvider) }
+ val passwordValidator = remember {
+ PasswordValidator(
+ stringProvider = stringProvider,
+ rules = provider.passwordValidationRules
+ )
+ }
+ val confirmPasswordValidator = remember(password) {
+ GeneralFieldValidator(
+ stringProvider = stringProvider,
+ isValid = { value ->
+ value == password
+ },
+ customMessage = stringProvider.passwordsDoNotMatch
+ )
+ }
+
+ val isFormValid = remember(displayName, email, password, confirmPassword) {
+ derivedStateOf {
+ listOf(
+ displayNameValidator.validate(displayName),
+ emailValidator.validate(email),
+ passwordValidator.validate(password),
+ confirmPasswordValidator.validate(confirmPassword)
+ ).all { it }
+ }
+ }
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = {
+ Text("Sign up")
+ },
+ colors = AuthUITheme.topAppBarColors
+ )
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(innerPadding)
+ .safeDrawingPadding()
+ .padding(horizontal = 16.dp),
+ ) {
+ AuthTextField(
+ value = email,
+ validator = emailValidator,
+ enabled = !isLoading,
+ label = {
+ Text("Email")
+ },
+ onValueChange = { text ->
+ onEmailChange(text)
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Email,
+ contentDescription = ""
+ )
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ if (provider.isDisplayNameRequired) {
+ AuthTextField(
+ value = displayName,
+ validator = displayNameValidator,
+ enabled = !isLoading,
+ label = {
+ Text("First & last Name")
+ },
+ onValueChange = { text ->
+ onDisplayNameChange(text)
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.AccountCircle,
+ contentDescription = ""
+ )
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ AuthTextField(
+ value = password,
+ validator = passwordValidator,
+ enabled = !isLoading,
+ isSecureTextField = true,
+ label = {
+ Text("Password")
+ },
+ onValueChange = { text ->
+ onPasswordChange(text)
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Lock,
+ contentDescription = ""
+ )
+ }
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ AuthTextField(
+ value = confirmPassword,
+ validator = confirmPasswordValidator,
+ enabled = !isLoading,
+ isSecureTextField = true,
+ label = {
+ Text("Confirm Password")
+ },
+ onValueChange = { text ->
+ onConfirmPasswordChange(text)
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Lock,
+ contentDescription = ""
+ )
+ }
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(
+ modifier = Modifier
+ .align(Alignment.End),
+ ) {
+ Button(
+ onClick = {
+ onGoToSignIn()
+ },
+ enabled = !isLoading,
+ ) {
+ Text("Sign In")
+ }
+ Spacer(modifier = Modifier.width(16.dp))
+ Button(
+ onClick = {
+ onSignUpClick()
+ },
+ enabled = !isLoading && isFormValid.value,
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .size(16.dp)
+ )
+ } else {
+ Text("Sign Up")
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ TermsAndPrivacyForm(
+ modifier = Modifier.align(Alignment.End),
+ tosUrl = configuration.tosUrl,
+ ppUrl = configuration.privacyPolicyUrl,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PreviewSignUpUI() {
+ val applicationContext = LocalContext.current
+ val provider = AuthProvider.Email(
+ isDisplayNameRequired = true,
+ isEmailLinkSignInEnabled = false,
+ isEmailLinkForceSameDeviceEnabled = true,
+ emailLinkActionCodeSettings = null,
+ isNewAccountsAllowed = true,
+ minimumPasswordLength = 8,
+ passwordValidationRules = listOf()
+ )
+
+ AuthUITheme {
+ SignUpUI(
+ configuration = authUIConfiguration {
+ context = applicationContext
+ providers { provider(provider) }
+ tosUrl = ""
+ privacyPolicyUrl = ""
+ },
+ isLoading = false,
+ displayName = "",
+ email = "",
+ password = "",
+ confirmPassword = "",
+ onDisplayNameChange = { name -> },
+ onEmailChange = { email -> },
+ onPasswordChange = { password -> },
+ onConfirmPasswordChange = { confirmPassword -> },
+ onSignUpClick = {},
+ onGoToSignIn = {}
+ )
+ }
+}
\ No newline at end of file
diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt
index 98e72d6fd..3b21b368a 100644
--- a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUIAuthStateTest.kt
@@ -287,25 +287,31 @@ class FirebaseAuthUIAuthStateTest {
// Given initial idle state
`when`(mockFirebaseAuth.currentUser).thenReturn(null)
- // When updating auth state internally
- authUI.updateAuthState(AuthState.Loading("Signing in..."))
-
- // Then the flow should reflect the updated state
+ // Start collecting the flow to capture initial state
val states = mutableListOf()
val job = launch {
- authUI.authStateFlow().take(2).toList(states)
+ authUI.authStateFlow().take(3).toList(states)
}
- // Update state again
+ // Wait for initial state to be collected
delay(100)
+
+ // When updating auth state internally
+ authUI.updateAuthState(AuthState.Loading("Signing in..."))
+
+ // Wait for state update to propagate
+ delay(100)
+
+ // Update state again
authUI.updateAuthState(AuthState.Cancelled)
job.join()
- // The first state should be Idle (initial), second should be Loading
- assertThat(states[0]).isEqualTo(AuthState.Idle)
- // Note: The internal state update may not be immediately visible in the flow
- // because the auth state listener overrides it
+ // Verify the emitted states
+ assertThat(states).hasSize(3)
+ assertThat(states[0]).isEqualTo(AuthState.Idle) // Initial state
+ assertThat(states[1]).isInstanceOf(AuthState.Loading::class.java) // After first update
+ assertThat(states[2]).isEqualTo(AuthState.Cancelled) // After second update
}
// =============================================================================================
diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt
index 31045ba13..39316e2ff 100644
--- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/AuthUIConfigurationTest.kt
@@ -83,7 +83,7 @@ class AuthUIConfigurationTest {
assertThat(config.tosUrl).isNull()
assertThat(config.privacyPolicyUrl).isNull()
assertThat(config.logo).isNull()
- assertThat(config.actionCodeSettings).isNull()
+ assertThat(config.passwordResetActionCodeSettings).isNull()
assertThat(config.isNewEmailAccountsAllowed).isTrue()
assertThat(config.isDisplayNameRequired).isTrue()
assertThat(config.isProviderChoiceAlwaysShown).isFalse()
@@ -94,7 +94,7 @@ class AuthUIConfigurationTest {
val customTheme = AuthUITheme.Default
val customStringProvider = mock(AuthUIStringProvider::class.java)
val customLocale = Locale.US
- val customActionCodeSettings = actionCodeSettings {
+ val customPasswordResetActionCodeSettings = actionCodeSettings {
url = "https://example.com/verify"
handleCodeInApp = true
}
@@ -123,7 +123,7 @@ class AuthUIConfigurationTest {
tosUrl = "https://example.com/tos"
privacyPolicyUrl = "https://example.com/privacy"
logo = Icons.Default.AccountCircle
- actionCodeSettings = customActionCodeSettings
+ passwordResetActionCodeSettings = customPasswordResetActionCodeSettings
isNewEmailAccountsAllowed = false
isDisplayNameRequired = false
isProviderChoiceAlwaysShown = true
@@ -140,7 +140,8 @@ class AuthUIConfigurationTest {
assertThat(config.tosUrl).isEqualTo("https://example.com/tos")
assertThat(config.privacyPolicyUrl).isEqualTo("https://example.com/privacy")
assertThat(config.logo).isEqualTo(Icons.Default.AccountCircle)
- assertThat(config.actionCodeSettings).isEqualTo(customActionCodeSettings)
+ assertThat(config.passwordResetActionCodeSettings)
+ .isEqualTo(customPasswordResetActionCodeSettings)
assertThat(config.isNewEmailAccountsAllowed).isFalse()
assertThat(config.isDisplayNameRequired).isFalse()
assertThat(config.isProviderChoiceAlwaysShown).isTrue()
@@ -308,7 +309,7 @@ class AuthUIConfigurationTest {
)
provider(
AuthProvider.Email(
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = listOf()
)
)
@@ -416,7 +417,7 @@ class AuthUIConfigurationTest {
"tosUrl",
"privacyPolicyUrl",
"logo",
- "actionCodeSettings",
+ "passwordResetActionCodeSettings",
"isNewEmailAccountsAllowed",
"isDisplayNameRequired",
"isProviderChoiceAlwaysShown"
diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt
index 3e6ab28ca..a07a8abc0 100644
--- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/AuthProviderTest.kt
@@ -34,7 +34,7 @@ class AuthProviderTest {
@Test
fun `email provider with valid configuration should succeed`() {
val provider = AuthProvider.Email(
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = listOf()
)
@@ -50,7 +50,7 @@ class AuthProviderTest {
val provider = AuthProvider.Email(
isEmailLinkSignInEnabled = true,
- actionCodeSettings = actionCodeSettings,
+ emailLinkActionCodeSettings = actionCodeSettings,
passwordValidationRules = listOf()
)
@@ -61,7 +61,7 @@ class AuthProviderTest {
fun `email provider with email link enabled but null action code settings should throw`() {
val provider = AuthProvider.Email(
isEmailLinkSignInEnabled = true,
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = listOf()
)
@@ -85,7 +85,7 @@ class AuthProviderTest {
val provider = AuthProvider.Email(
isEmailLinkSignInEnabled = true,
- actionCodeSettings = actionCodeSettings,
+ emailLinkActionCodeSettings = actionCodeSettings,
passwordValidationRules = listOf()
)
@@ -319,7 +319,7 @@ class AuthProviderTest {
val providers = listOf(
AuthProvider.Anonymous,
AuthProvider.Email(
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = listOf()
)
)
diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt
index 79bbbcfef..00ebc75c4 100644
--- a/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/compose/configuration/auth_provider/EmailAuthProviderFirebaseAuthUITest.kt
@@ -123,7 +123,7 @@ class EmailAuthProviderFirebaseAuthUITest {
val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
val emailProvider = AuthProvider.Email(
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = emptyList()
)
val config = authUIConfiguration {
@@ -165,7 +165,7 @@ class EmailAuthProviderFirebaseAuthUITest {
).thenReturn(taskCompletionSource.task)
val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
val emailProvider = AuthProvider.Email(
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = emptyList()
)
val config = authUIConfiguration {
@@ -196,7 +196,7 @@ class EmailAuthProviderFirebaseAuthUITest {
fun `createOrLinkUserWithEmailAndPassword - rejects weak password`() = runTest {
val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
val emailProvider = AuthProvider.Email(
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = emptyList()
)
val config = authUIConfiguration {
@@ -231,7 +231,7 @@ class EmailAuthProviderFirebaseAuthUITest {
fun `createOrLinkUserWithEmailAndPassword - validates custom password rules`() = runTest {
val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
val emailProvider = AuthProvider.Email(
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = listOf(PasswordRule.RequireUppercase)
)
val config = authUIConfiguration {
@@ -259,7 +259,7 @@ class EmailAuthProviderFirebaseAuthUITest {
fun `createOrLinkUserWithEmailAndPassword - respects isNewAccountsAllowed setting`() = runTest {
val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
val emailProvider = AuthProvider.Email(
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = emptyList(),
isNewAccountsAllowed = false
)
@@ -303,7 +303,7 @@ class EmailAuthProviderFirebaseAuthUITest {
val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
val emailProvider = AuthProvider.Email(
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = emptyList()
)
val config = authUIConfiguration {
@@ -348,7 +348,7 @@ class EmailAuthProviderFirebaseAuthUITest {
val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
val emailProvider = AuthProvider.Email(
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = emptyList()
)
val config = authUIConfiguration {
@@ -383,7 +383,7 @@ class EmailAuthProviderFirebaseAuthUITest {
val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
val emailProvider = AuthProvider.Email(
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = emptyList()
)
val config = authUIConfiguration {
@@ -419,7 +419,7 @@ class EmailAuthProviderFirebaseAuthUITest {
val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
val emailProvider = AuthProvider.Email(
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = emptyList()
)
val config = authUIConfiguration {
@@ -463,7 +463,7 @@ class EmailAuthProviderFirebaseAuthUITest {
val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
val emailProvider = AuthProvider.Email(
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = emptyList()
)
val config = authUIConfiguration {
@@ -501,7 +501,7 @@ class EmailAuthProviderFirebaseAuthUITest {
val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
val emailProvider = AuthProvider.Email(
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = emptyList()
)
val config = authUIConfiguration {
@@ -537,7 +537,7 @@ class EmailAuthProviderFirebaseAuthUITest {
val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
val emailProvider = AuthProvider.Email(
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = emptyList()
)
val config = authUIConfiguration {
@@ -583,7 +583,7 @@ class EmailAuthProviderFirebaseAuthUITest {
val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth)
val emailProvider = AuthProvider.Email(
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = emptyList()
)
val config = authUIConfiguration {
diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt
index 3baa351e0..c68f1e6e9 100644
--- a/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthProviderButtonTest.kt
@@ -111,7 +111,7 @@ class AuthProviderButtonTest {
@Test
fun `AuthProviderButton displays Email provider correctly`() {
val provider = AuthProvider.Email(
- actionCodeSettings = null,
+ emailLinkActionCodeSettings = null,
passwordValidationRules = emptyList()
)
diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt
index 21629a1a2..101317483 100644
--- a/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/components/AuthTextFieldTest.kt
@@ -355,12 +355,13 @@ class AuthTextFieldTest {
// =============================================================================================
@Test
- fun `AuthTextField shows password visibility toggle for PasswordValidator`() {
+ fun `AuthTextField shows password visibility toggle when isSecureTextField`() {
composeTestRule.setContent {
AuthTextField(
value = "password123",
onValueChange = { },
label = { Text("Password") },
+ isSecureTextField = true,
validator = PasswordValidator(
stringProvider = stringProvider,
rules = emptyList()
@@ -380,6 +381,7 @@ class AuthTextFieldTest {
value = "password123",
onValueChange = { },
label = { Text("Password") },
+ isSecureTextField = true,
validator = PasswordValidator(
stringProvider = stringProvider,
rules = emptyList()
diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt
index 2b500a924..bdb1578ff 100644
--- a/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt
+++ b/auth/src/test/java/com/firebase/ui/auth/compose/ui/method_picker/AuthMethodPickerTest.kt
@@ -56,7 +56,10 @@ class AuthMethodPickerTest {
val providers = listOf(
AuthProvider.Google(scopes = emptyList(), serverClientId = null),
AuthProvider.Facebook(),
- AuthProvider.Email(actionCodeSettings = null, passwordValidationRules = emptyList())
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ )
)
composeTestRule.setContent {
@@ -277,7 +280,10 @@ class AuthMethodPickerTest {
AuthProvider.Microsoft(tenant = null, customParameters = emptyMap()),
AuthProvider.Yahoo(customParameters = emptyMap()),
AuthProvider.Apple(locale = null, customParameters = emptyMap()),
- AuthProvider.Email(actionCodeSettings = null, passwordValidationRules = emptyList()),
+ AuthProvider.Email(
+ emailLinkActionCodeSettings = null,
+ passwordValidationRules = emptyList()
+ ),
AuthProvider.Phone(
defaultNumber = null,
defaultCountryCode = null,
diff --git a/build.gradle b/build.gradle
index e75ec13b3..b3a5a9caa 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,2 +1,10 @@
-// This empty file along with settings.gradle help Android Studio recognize the project
+buildscript {
+ dependencies {
+ classpath libs.kotlin.gradle.plugin
+ }
+}
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.compose.compiler) apply false
+}// This empty file along with settings.gradle help Android Studio recognize the project
// as a gradle project, despite the use of .gradle.kts scripts.
\ No newline at end of file
diff --git a/composeapp/.gitignore b/composeapp/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/composeapp/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/composeapp/build.gradle.kts b/composeapp/build.gradle.kts
new file mode 100644
index 000000000..92f555ad7
--- /dev/null
+++ b/composeapp/build.gradle.kts
@@ -0,0 +1,68 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.compose")
+ id("com.google.gms.google-services") apply false
+}
+
+android {
+ namespace = "com.firebase.composeapp"
+ compileSdk = Config.SdkVersions.compile
+
+ defaultConfig {
+ applicationId = "com.firebase.composeapp"
+ minSdk = Config.SdkVersions.min
+ targetSdk = Config.SdkVersions.target
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ implementation(project(":auth"))
+
+ implementation(Config.Libs.Kotlin.jvm)
+ implementation(Config.Libs.Androidx.lifecycleRuntime)
+ implementation(Config.Libs.Androidx.Compose.activityCompose)
+ implementation(platform(Config.Libs.Androidx.Compose.bom))
+ implementation(Config.Libs.Androidx.Compose.ui)
+ implementation(Config.Libs.Androidx.Compose.uiGraphics)
+ implementation(Config.Libs.Androidx.Compose.toolingPreview)
+ implementation(Config.Libs.Androidx.Compose.material3)
+
+ testImplementation(Config.Libs.Test.junit)
+ androidTestImplementation(Config.Libs.Test.junitExt)
+ androidTestImplementation(platform(Config.Libs.Androidx.Compose.bom))
+ androidTestImplementation(Config.Libs.Test.composeUiTestJunit4)
+
+ debugImplementation(Config.Libs.Androidx.Compose.tooling)
+
+ implementation(platform(Config.Libs.Firebase.bom))
+}
+
+// Only apply google-services plugin if the google-services.json file exists
+if (rootProject.file("composeapp/google-services.json").exists()) {
+ apply(plugin = "com.google.gms.google-services")
+}
\ No newline at end of file
diff --git a/composeapp/proguard-rules.pro b/composeapp/proguard-rules.pro
new file mode 100644
index 000000000..481bb4348
--- /dev/null
+++ b/composeapp/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/composeapp/src/main/AndroidManifest.xml b/composeapp/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..99b6bad7d
--- /dev/null
+++ b/composeapp/src/main/AndroidManifest.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt
new file mode 100644
index 000000000..decb7a452
--- /dev/null
+++ b/composeapp/src/main/java/com/firebase/composeapp/MainActivity.kt
@@ -0,0 +1,103 @@
+package com.firebase.composeapp
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.lifecycleScope
+import com.firebase.composeapp.ui.screens.MainScreen
+import com.firebase.ui.auth.compose.FirebaseAuthUI
+import com.firebase.ui.auth.compose.configuration.PasswordRule
+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.auth_provider.signInWithEmailLink
+import com.firebase.ui.auth.compose.configuration.theme.AuthUITheme
+import com.firebase.ui.auth.compose.ui.screens.EmailSignInLinkHandlerActivity
+import com.firebase.ui.auth.compose.util.EmailLinkPersistenceManager
+import com.google.firebase.FirebaseApp
+import com.google.firebase.auth.actionCodeSettings
+import kotlinx.coroutines.launch
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ FirebaseApp.initializeApp(applicationContext)
+ val authUI = FirebaseAuthUI.getInstance()
+
+ // Check if this is an email link sign-in flow
+ val emailLink = intent.getStringExtra(
+ EmailSignInLinkHandlerActivity.EXTRA_EMAIL_LINK
+ )
+
+ val provider = AuthProvider.Email(
+ isDisplayNameRequired = true,
+ isEmailLinkSignInEnabled = true,
+ isEmailLinkForceSameDeviceEnabled = true,
+ emailLinkActionCodeSettings = actionCodeSettings {
+ // The continue URL - where to redirect after email link is clicked
+ url = "https://temp-test-aa342.firebaseapp.com"
+ handleCodeInApp = true
+ setAndroidPackageName(
+ "com.firebase.composeapp",
+ true,
+ null
+ )
+ },
+ isNewAccountsAllowed = true,
+ minimumPasswordLength = 8,
+ passwordValidationRules = listOf(
+ PasswordRule.MinimumLength(8),
+ PasswordRule.RequireLowercase,
+ PasswordRule.RequireUppercase,
+ )
+ )
+
+ val configuration = authUIConfiguration {
+ context = applicationContext
+ providers { provider(provider) }
+ tosUrl = "https://policies.google.com/terms?hl=en-NG&fg=1"
+ privacyPolicyUrl = "https://policies.google.com/privacy?hl=en-NG&fg=1"
+ }
+
+ if (emailLink != null) {
+ lifecycleScope.launch {
+ try {
+ val emailFromSession = EmailLinkPersistenceManager
+ .retrieveSessionRecord(applicationContext)?.email
+
+ if (emailFromSession != null) {
+ authUI.signInWithEmailLink(
+ context = applicationContext,
+ config = configuration,
+ provider = provider,
+ email = emailFromSession,
+ emailLink = emailLink,
+ )
+ }
+ } catch (e: Exception) {
+ // Error handling is done via AuthState.Error in the auth flow
+ }
+ }
+ }
+
+ setContent {
+ AuthUITheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background
+ ) {
+ MainScreen(
+ context = applicationContext,
+ configuration = configuration,
+ authUI = authUI,
+ provider = provider
+ )
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/screens/MainScreen.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/MainScreen.kt
new file mode 100644
index 000000000..4e1aeaf66
--- /dev/null
+++ b/composeapp/src/main/java/com/firebase/composeapp/ui/screens/MainScreen.kt
@@ -0,0 +1,151 @@
+package com.firebase.composeapp.ui.screens
+
+import android.content.Context
+import android.util.Log
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.firebase.ui.auth.compose.AuthState
+import com.firebase.ui.auth.compose.FirebaseAuthUI
+import com.firebase.ui.auth.compose.configuration.AuthUIConfiguration
+import com.firebase.ui.auth.compose.configuration.auth_provider.AuthProvider
+import com.firebase.ui.auth.compose.ui.screens.EmailAuthMode
+import com.firebase.ui.auth.compose.ui.screens.EmailAuthScreen
+import com.firebase.ui.auth.compose.ui.screens.ResetPasswordUI
+import com.firebase.ui.auth.compose.ui.screens.SignInUI
+import com.firebase.ui.auth.compose.ui.screens.SignUpUI
+import kotlinx.coroutines.launch
+
+@Composable
+fun MainScreen(
+ context: Context,
+ configuration: AuthUIConfiguration,
+ authUI: FirebaseAuthUI,
+ provider: AuthProvider.Email
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val authState by authUI.authStateFlow().collectAsState(AuthState.Idle)
+
+ when (authState) {
+ is AuthState.Success -> {
+ Column(
+ modifier = Modifier
+ .fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text("Authenticated User - (Success): ${authUI.getCurrentUser()?.email}",
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(
+ onClick = {
+ coroutineScope.launch {
+ authUI.signOut(context)
+ }
+ }
+ ) {
+ Text("Sign Out")
+ }
+ }
+ }
+
+ is AuthState.RequiresEmailVerification -> {
+ Column(
+ modifier = Modifier
+ .fillMaxSize(),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(
+ "Authenticated User - " +
+ "(RequiresEmailVerification): " +
+ "${(authState as AuthState.RequiresEmailVerification).user.email}",
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(
+ onClick = {
+ coroutineScope.launch {
+ authUI.signOut(context)
+ }
+ }
+ ) {
+ Text("Sign Out")
+ }
+ }
+ }
+
+ else -> {
+ EmailAuthScreen(
+ context = context,
+ configuration = configuration,
+ authUI = authUI,
+ onSuccess = { result -> },
+ onError = { exception -> },
+ onCancel = { },
+ ) { state ->
+ when (state.mode) {
+ EmailAuthMode.SignIn -> {
+ SignInUI(
+ configuration = configuration,
+ email = state.email,
+ isLoading = state.isLoading,
+ emailSignInLinkSent = state.emailSignInLinkSent,
+ password = state.password,
+ onEmailChange = state.onEmailChange,
+ onPasswordChange = state.onPasswordChange,
+ onSignInClick = state.onSignInClick,
+ onGoToSignUp = state.onGoToSignUp,
+ onGoToResetPassword = state.onGoToResetPassword,
+ )
+ }
+
+ EmailAuthMode.SignUp -> {
+ SignUpUI(
+ configuration = configuration,
+ isLoading = state.isLoading,
+ displayName = state.displayName,
+ email = state.email,
+ password = state.password,
+ confirmPassword = state.confirmPassword,
+ onDisplayNameChange = state.onDisplayNameChange,
+ onEmailChange = state.onEmailChange,
+ onPasswordChange = state.onPasswordChange,
+ onConfirmPasswordChange = state.onConfirmPasswordChange,
+ onSignUpClick = state.onSignUpClick,
+ onGoToSignIn = state.onGoToSignIn,
+ )
+ }
+
+ EmailAuthMode.ResetPassword -> {
+ ResetPasswordUI(
+ configuration = configuration,
+ isLoading = state.isLoading,
+ email = state.email,
+ resetLinkSent = state.resetLinkSent,
+ onEmailChange = state.onEmailChange,
+ onSendResetLink = state.onSendResetLinkClick,
+ onGoToSignIn = state.onGoToSignIn
+ )
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Color.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Color.kt
new file mode 100644
index 000000000..994915613
--- /dev/null
+++ b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Color.kt
@@ -0,0 +1,11 @@
+package com.firebase.composeapp.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
\ No newline at end of file
diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Theme.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Theme.kt
new file mode 100644
index 000000000..08bd9441a
--- /dev/null
+++ b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Theme.kt
@@ -0,0 +1,58 @@
+package com.firebase.composeapp.ui.theme
+
+import android.app.Activity
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+
+ /* Other default colors to override
+ background = Color(0xFFFFFBFE),
+ surface = Color(0xFFFFFBFE),
+ onPrimary = Color.White,
+ onSecondary = Color.White,
+ onTertiary = Color.White,
+ onBackground = Color(0xFF1C1B1F),
+ onSurface = Color(0xFF1C1B1F),
+ */
+)
+
+@Composable
+fun FirebaseUIAndroidTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Dynamic color is available on Android 12+
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Type.kt b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Type.kt
new file mode 100644
index 000000000..ea0657654
--- /dev/null
+++ b/composeapp/src/main/java/com/firebase/composeapp/ui/theme/Type.kt
@@ -0,0 +1,34 @@
+package com.firebase.composeapp.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Set of Material typography styles to start with
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+ /* Other default text styles to override
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 22.sp,
+ lineHeight = 28.sp,
+ letterSpacing = 0.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ )
+ */
+)
\ No newline at end of file
diff --git a/composeapp/src/main/res/drawable-v24/ic_launcher_foreground.xml b/composeapp/src/main/res/drawable-v24/ic_launcher_foreground.xml
new file mode 100644
index 000000000..fde1368fc
--- /dev/null
+++ b/composeapp/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/composeapp/src/main/res/drawable/ic_launcher_background.xml b/composeapp/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 000000000..1e4408cae
--- /dev/null
+++ b/composeapp/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000..6f3b755bf
--- /dev/null
+++ b/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 000000000..6f3b755bf
--- /dev/null
+++ b/composeapp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/composeapp/src/main/res/mipmap-hdpi/ic_launcher.webp b/composeapp/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 000000000..c209e78ec
Binary files /dev/null and b/composeapp/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/composeapp/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/composeapp/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..b2dfe3d1b
Binary files /dev/null and b/composeapp/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/composeapp/src/main/res/mipmap-mdpi/ic_launcher.webp b/composeapp/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 000000000..4f0f1d64e
Binary files /dev/null and b/composeapp/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/composeapp/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/composeapp/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..62b611da0
Binary files /dev/null and b/composeapp/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/composeapp/src/main/res/mipmap-xhdpi/ic_launcher.webp b/composeapp/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 000000000..948a3070f
Binary files /dev/null and b/composeapp/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/composeapp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/composeapp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..1b9a6956b
Binary files /dev/null and b/composeapp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/composeapp/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/composeapp/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 000000000..28d4b77f9
Binary files /dev/null and b/composeapp/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/composeapp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/composeapp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..9287f5083
Binary files /dev/null and b/composeapp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/composeapp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/composeapp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 000000000..aa7d6427e
Binary files /dev/null and b/composeapp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/composeapp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/composeapp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 000000000..9126ae37c
Binary files /dev/null and b/composeapp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/composeapp/src/main/res/values/colors.xml b/composeapp/src/main/res/values/colors.xml
new file mode 100644
index 000000000..f8c6127d3
--- /dev/null
+++ b/composeapp/src/main/res/values/colors.xml
@@ -0,0 +1,10 @@
+
+
+ #FFBB86FC
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/composeapp/src/main/res/values/strings.xml b/composeapp/src/main/res/values/strings.xml
new file mode 100644
index 000000000..c226d8f8f
--- /dev/null
+++ b/composeapp/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ ComposeApp
+
\ No newline at end of file
diff --git a/composeapp/src/main/res/values/themes.xml b/composeapp/src/main/res/values/themes.xml
new file mode 100644
index 000000000..1f225670b
--- /dev/null
+++ b/composeapp/src/main/res/values/themes.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/settings.gradle b/settings.gradle
index dd063c8b0..275ad6d0a 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -22,17 +22,17 @@ gradleEnterprise {
rootProject.buildFileName = 'build.gradle.kts'
include(
- ":app",
-
+ ":app",
+ ":composeapp",
":library",
":auth",
- ":common",
+ ":common",
":database",
- ":firestore",
+ ":firestore",
":storage",
":lint",
- ":proguard-tests",
- ":internal:lint",
+ ":proguard-tests",
+ ":internal:lint",
":internal:lintchecks"
)