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 @@ + + + +