diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bb74b0a75..abcf1afd2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -68,6 +68,7 @@ dependencies { implementation(libs.compose.ui.graphics) implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.material3) + implementation(libs.compose.material.icons.extended) // Facebook implementation(libs.facebook.login) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 77abc5776..d9ff55383 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -88,6 +88,12 @@ android:exported="false" android:theme="@style/Theme.FirebaseUIAndroid" /> + + + // customMethodPickerLayout now renders as the entire screen (no + // built-in logo/ToS footer/inset handling), so the terms checkbox + // that used to live in customMethodPickerTermsConfiguration is + // rendered inline here instead, and this composable owns its own + // insets via Modifier.safeDrawingPadding() in SpotlightMethodPicker. SpotlightMethodPicker( providers = providers, onProviderSelected = onProviderSelected, @@ -181,6 +186,8 @@ fun SpotlightMethodPicker( val anonymous = groups["anonymous"]?.firstOrNull() LazyColumn( + // customMethodPickerLayout now renders as the entire screen, so this composable is + // responsible for its own insets. modifier = Modifier .fillMaxSize() .safeDrawingPadding(), @@ -298,7 +305,7 @@ fun SpotlightMethodPicker( } @Composable -private fun ProviderIconButton( +fun ProviderIconButton( style: AuthUITheme.ProviderStyle, contentDescription: String, onClick: () -> Unit, @@ -335,12 +342,12 @@ private fun ProviderIconButton( } @Composable -private fun AuthUIAsset.asPainter(): Painter = when (this) { +fun AuthUIAsset.asPainter(): Painter = when (this) { is AuthUIAsset.Resource -> painterResource(resId) is AuthUIAsset.Vector -> rememberVectorPainter(image) } -private fun styleForProvider(provider: AuthProvider): AuthUITheme.ProviderStyle = when (provider) { +fun styleForProvider(provider: AuthProvider): AuthUITheme.ProviderStyle = when (provider) { is AuthProvider.Facebook -> ProviderStyleDefaults.Facebook is AuthProvider.Twitter -> ProviderStyleDefaults.Twitter is AuthProvider.Github -> ProviderStyleDefaults.Github diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt index df069091f..d270beac5 100644 --- a/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt +++ b/app/src/main/java/com/firebaseui/android/demo/auth/CustomSlotsThemingDemoActivity.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.firebaseui.android.demo.auth.fullcustomization.FullCustomizationDemoActivity class CustomSlotsThemingDemoActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -46,6 +47,9 @@ class CustomSlotsThemingDemoActivity : ComponentActivity() { }, onCustomMethodPickerClick = { startActivity(Intent(this, CustomMethodPickerDemoActivity::class.java)) + }, + onFullCustomizationClick = { + startActivity(Intent(this, FullCustomizationDemoActivity::class.java)) } ) } @@ -60,6 +64,7 @@ fun CustomSlotsDemoChooser( onPhoneAuthSlotClick: () -> Unit, onShapeCustomizationClick: () -> Unit, onCustomMethodPickerClick: () -> Unit, + onFullCustomizationClick: () -> Unit, ) { Column( modifier = Modifier @@ -106,6 +111,12 @@ fun CustomSlotsDemoChooser( description = "Replace the default provider list with a custom layout, and swap the 'By continuing...' footer with a checkbox using customMethodPickerLayout and customMethodPickerTermsConfiguration on FirebaseAuthScreen.", onClick = onCustomMethodPickerClick ) + + DemoCard( + title = "Full Customization", + description = "customMethodPickerLayout renders as the entire screen, so this layers a full-bleed background image and scrim behind the custom method picker.", + onClick = onFullCustomizationClick + ) } } diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationDemoActivity.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationDemoActivity.kt new file mode 100644 index 000000000..b82eeff9f --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/FullCustomizationDemoActivity.kt @@ -0,0 +1,184 @@ +package com.firebaseui.android.demo.auth.fullcustomization + +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.lifecycle.lifecycleScope +import com.firebase.ui.auth.AuthException +import com.firebase.ui.auth.FirebaseAuthUI +import com.firebase.ui.auth.configuration.AuthUIConfiguration +import com.firebase.ui.auth.configuration.MfaConfiguration +import com.firebase.ui.auth.configuration.MfaFactor +import com.firebase.ui.auth.configuration.authUIConfiguration +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.theme.AuthUIAsset +import com.firebase.ui.auth.ui.screens.FirebaseAuthScreen +import com.firebase.ui.auth.ui.screens.email.EmailAuthScreen +import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.screens.AuthMethodPickerUI +import com.firebaseui.android.demo.auth.fullcustomization.screens.AuthenticatedUI +import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.MfaChallengeUI +import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.MfaEnrollmentUI +import com.firebaseui.android.demo.auth.fullcustomization.screens.phone.PhoneSignInUI +import com.firebaseui.android.demo.auth.fullcustomization.screens.reauth.ReauthUI +import com.firebaseui.android.demo.auth.fullcustomization.theme.FullCustomizationTheme +import kotlinx.coroutines.launch + +class FullCustomizationDemoActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val authUI = FirebaseAuthUI.getInstance() + val configuration = authUIConfiguration { + context = applicationContext + logo = AuthUIAsset.Resource(R.drawable.firebase_auth) + tosUrl = "https://policies.google.com/terms" + privacyPolicyUrl = "https://policies.google.com/privacy" + providers { + provider( + AuthProvider.Google( + scopes = listOf("email"), + serverClientId = "406099696497-a12gakvts4epfk5pkio7dphc1anjiggc.apps.googleusercontent.com", + ) + ) + provider(AuthProvider.Apple(customParameters = emptyMap(), locale = null)) + provider(AuthProvider.Facebook()) + provider(AuthProvider.Twitter(customParameters = emptyMap())) + provider(AuthProvider.Github(customParameters = emptyMap())) + provider(AuthProvider.Microsoft(tenant = null, customParameters = emptyMap())) + provider(AuthProvider.Yahoo(customParameters = emptyMap())) + provider( + AuthProvider.Email( + emailLinkActionCodeSettings = null, + passwordValidationRules = emptyList() + ) + ) + provider( + AuthProvider.Phone( + defaultNumber = null, + defaultCountryCode = null, + allowedCountries = null + ) + ) + provider(AuthProvider.Anonymous) + } + } + + setContent { + FullCustomizationTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + FirebaseAuthScreen( + configuration = configuration, + authUI = authUI, + onSignInSuccess = { result -> + Log.d("FullCustomizationDemo", "Auth success: ${result.user?.uid}") + }, + onSignInFailure = { exception: AuthException -> + Log.e("FullCustomizationDemo", "Auth failed", exception) + }, + onSignInCancelled = { + Log.d("FullCustomizationDemo", "Auth cancelled") + }, + mfaConfiguration = MfaConfiguration( + allowedFactors = listOf(MfaFactor.Sms, MfaFactor.Totp), + requireEnrollment = false, + enableRecoveryCodes = true, + ), + customMethodPickerLayout = { providers, onProviderSelected -> + MainUI( + authUI = authUI, + configuration = configuration, + providers = providers, + onProviderSelected = onProviderSelected, + ) + }, + phoneContent = { state -> PhoneSignInUI(state) }, + mfaEnrollmentContent = { state -> MfaEnrollmentUI(state) }, + mfaChallengeContent = { state -> MfaChallengeUI(state) }, + reauthContent = { reauthState, onDismiss -> + ReauthUI( + state = reauthState, + onDismiss = onDismiss, + onVerified = { + val retry = reauthState.retryOperation + // Dismiss first: onDismiss resets the auth state to Idle, so + // running the retry afterwards keeps the Success it reports + // from being clobbered. Neither call suspends, so the + // composable's cancellation can't interleave between them — + // and the retry itself runs on lifecycleScope, which outlives + // this UI. + onDismiss() + retry?.let { + lifecycleScope.launch { it(applicationContext) } + } + }, + ) + }, + authenticatedContent = { state, uiContext -> + AuthenticatedUI(state = state, uiContext = uiContext) + }, + ) + } + } + } + } +} + +@Composable +private fun MainUI( + authUI: FirebaseAuthUI, + configuration: AuthUIConfiguration, + providers: List, + onProviderSelected: (AuthProvider) -> Unit, +) { + val context = LocalContext.current + Box(modifier = Modifier.fillMaxSize()) { + Image( + painter = painterResource(id = R.drawable.custom_background), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + Column(modifier = Modifier.fillMaxSize()) { + EmailAuthScreen( + context = context, + configuration = configuration, + authUI = authUI, + onSuccess = { result -> + Log.d("FullCustomizationDemo", "Auth success: ${result.user?.uid}") + }, + onError = { exception -> + Log.e("FullCustomizationDemo", "Auth failed", exception) + }, + onCancel = { + Log.d("FullCustomizationDemo", "Auth cancelled") + }, + ) { state -> + AuthMethodPickerUI( + state = state, + otherProviders = providers.filterNot { it is AuthProvider.Email }, + onProviderSelected = onProviderSelected, + tosUrl = configuration.tosUrl, + ppUrl = configuration.privacyPolicyUrl, + ) + } + } + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthPage.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthPage.kt new file mode 100644 index 000000000..b96e8090d --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthPage.kt @@ -0,0 +1,115 @@ +package com.firebaseui.android.demo.auth.fullcustomization.common + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.firebaseui.android.demo.R + +/** + * The page frame shared by the MFA and reauthentication screens: mascot, headline, a single + * elevated card, and bottom-anchored actions. + * + * The email and phone steps predate this and inline the same structure themselves. + * + * verticalScroll measures content with infinite max height, and Column distributes weights + * against the MIN height when max is infinite (RowColumnMeasurePolicy.kt) — so + * heightIn(min = viewport) makes the weighted spacers expand (centering content, anchoring the + * actions to the bottom) when everything fits, and collapse to zero (plain scrolling) when it + * doesn't. + */ +@Composable +fun AuthPage( + @DrawableRes mascot: Int, + mascotDescription: String, + title: String, + cardContentDescription: String, + actions: @Composable ColumnScope.() -> Unit, + card: @Composable ColumnScope.() -> Unit, +) { + Box(modifier = Modifier.fillMaxSize()) { + // Full-bleed, and deliberately outside the safeDrawingPadding below so it runs edge to + // edge under the system bars — same as MainUI and PhoneSignInUI do for their slots. + Image( + painter = painterResource(id = R.drawable.custom_background), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight) + .padding(horizontal = 40.dp, vertical = 24.dp), + ) { + Spacer(modifier = Modifier.weight(1f)) + + Column(modifier = Modifier.fillMaxWidth()) { + Image( + painter = painterResource(id = mascot), + contentDescription = mascotDescription, + modifier = Modifier.size(72.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(24.dp)) + + HardOffsetShadow(shape = AuthFieldShape, modifier = Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = cardContentDescription }, + color = MaterialTheme.colorScheme.surface, + shape = AuthFieldShape, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + content = card, + ) + } + } + } + + Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(24.dp)) + + Column(modifier = Modifier.fillMaxWidth(), content = actions) + } + } + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthTextFieldStyle.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthTextFieldStyle.kt new file mode 100644 index 000000000..ca66ee7bc --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/AuthTextFieldStyle.kt @@ -0,0 +1,75 @@ +package com.firebaseui.android.demo.auth.fullcustomization.common + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.firebaseui.android.demo.R + +val AuthFieldShape = RoundedCornerShape(24.dp) + +@Composable +fun authTextFieldColors(): TextFieldColors = OutlinedTextFieldDefaults.colors( + unfocusedContainerColor = Color.White, + focusedContainerColor = Color.White, + disabledContainerColor = Color.White, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant, + focusedBorderColor = MaterialTheme.colorScheme.secondary, +) + +@Composable +fun FullCustomizationTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + placeholder: String? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + enabled: Boolean = true, + isError: Boolean = false, + supportingText: String? = null, + singleLine: Boolean = true, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + shape: Shape = AuthFieldShape, +) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + label = label?.let { { Text(it) } }, + placeholder = placeholder?.let { { Text(it) } }, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + enabled = enabled, + isError = isError, + supportingText = supportingText?.let { { Text(it) } }, + singleLine = singleLine, + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + shape = shape, + colors = authTextFieldColors(), + ) +} + +@Composable +fun EmailFieldIcon() { + Image( + painter = painterResource(R.drawable.email_at_sign), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/CtaButton.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/CtaButton.kt new file mode 100644 index 000000000..94317a9e1 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/CtaButton.kt @@ -0,0 +1,54 @@ +package com.firebaseui.android.demo.auth.fullcustomization.common + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.firebaseui.android.demo.auth.fullcustomization.theme.ButtonShape + +private val CtaShadowColor = Color(0xFF5D0B47) + +@Composable +fun CtaButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + isLoading: Boolean = false, + colors: ButtonColors = ButtonDefaults.buttonColors(), +) { + HardOffsetShadow( + shape = ButtonShape, + offsetX = 2.dp, + offsetY = 4.dp, + color = if (enabled) CtaShadowColor else Color.Transparent, + modifier = modifier.fillMaxWidth(), + ) { + Button( + onClick = onClick, + enabled = enabled, + shape = ButtonShape, + colors = colors, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 10.dp), + modifier = Modifier + .fillMaxWidth() + .height(80.dp), + ) { + if (isLoading) { + CircularProgressIndicator(modifier = Modifier.size(20.dp)) + } else { + Text(text = text, style = MaterialTheme.typography.titleMedium) + } + } + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/HardOffsetShadow.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/HardOffsetShadow.kt new file mode 100644 index 000000000..628bffafd --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/HardOffsetShadow.kt @@ -0,0 +1,32 @@ +package com.firebaseui.android.demo.auth.fullcustomization.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun HardOffsetShadow( + shape: Shape, + modifier: Modifier = Modifier, + offsetX: Dp = 3.dp, + offsetY: Dp = 6.dp, + color: Color = MaterialTheme.colorScheme.primaryContainer, + content: @Composable () -> Unit, +) { + Box(modifier = modifier) { + Box( + modifier = Modifier + .matchParentSize() + .offset(x = offsetX, y = offsetY) + .background(color = color, shape = shape), + ) + content() + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/OtherSignInMethodsSheet.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/OtherSignInMethodsSheet.kt new file mode 100644 index 000000000..16e5ca91a --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/OtherSignInMethodsSheet.kt @@ -0,0 +1,70 @@ +package com.firebaseui.android.demo.auth.fullcustomization.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.ui.components.TermsAndPrivacyForm + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OtherSignInMethodsSheet( + otherProviders: List, + onProviderSelected: (AuthProvider) -> Unit, + onDismissRequest: () -> Unit, + tosUrl: String?, + ppUrl: String?, +) { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + containerColor = MaterialTheme.colorScheme.primaryContainer, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 64.dp), + ) { + Text( + text = "Other sign in methods", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .semantics { contentDescription = "Other sign-in methods sheet title" }, + ) + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxWidth(), + ) { + otherProviders.forEach { provider -> + SheetProviderButton( + provider = provider, + onClick = { + onDismissRequest() + onProviderSelected(provider) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + TermsAndPrivacyForm(tosUrl = tosUrl, ppUrl = ppUrl) + Spacer(modifier = Modifier.height(24.dp)) + } + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/SheetProviderButton.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/SheetProviderButton.kt new file mode 100644 index 000000000..4b8583543 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/common/SheetProviderButton.kt @@ -0,0 +1,121 @@ +package com.firebaseui.android.demo.auth.fullcustomization.common + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Phone +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.configuration.theme.AuthUIAsset +import com.firebase.ui.auth.configuration.theme.ProviderStyleDefaults +import com.firebaseui.android.demo.auth.fullcustomization.theme.ProviderButtonShape + +@Composable +fun SheetProviderButton( + provider: AuthProvider, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val label = providerSheetLabel(provider) + val style = when (provider) { + is AuthProvider.Google -> ProviderStyleDefaults.Google + is AuthProvider.Facebook -> ProviderStyleDefaults.Facebook + is AuthProvider.Twitter -> ProviderStyleDefaults.Twitter + is AuthProvider.Github -> ProviderStyleDefaults.Github + is AuthProvider.Microsoft -> ProviderStyleDefaults.Microsoft + is AuthProvider.Yahoo -> ProviderStyleDefaults.Yahoo + is AuthProvider.Apple -> ProviderStyleDefaults.Apple + is AuthProvider.Anonymous -> ProviderStyleDefaults.Anonymous + else -> ProviderStyleDefaults.Email + } + val backgroundColor = if (provider is AuthProvider.Phone) { + MaterialTheme.colorScheme.primary + } else { + style.backgroundColor + } + val contentColor = if (provider is AuthProvider.Google) Color.Black else style.contentColor + val hasWhiteBackground = backgroundColor == Color.White + + Button( + onClick = onClick, + shape = ProviderButtonShape, + colors = ButtonDefaults.buttonColors( + containerColor = backgroundColor, + contentColor = contentColor, + ), + border = if (hasWhiteBackground) BorderStroke(1.dp, Color.Black) else null, + contentPadding = PaddingValues(horizontal = 36.dp, vertical = 12.dp), + modifier = modifier, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + if (provider is AuthProvider.Phone) { + Icon( + imageVector = Icons.Default.Phone, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + } else { + style.icon?.let { icon -> + Image( + painter = icon.asPainter(), + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + } + } + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = label, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + style = MaterialTheme.typography.labelLarge, + ) + } + } +} + +private fun providerSheetLabel(provider: AuthProvider): String = when (provider) { + is AuthProvider.Google -> "Sign in with Google" + is AuthProvider.Facebook -> "Sign in with Facebook" + is AuthProvider.Twitter -> "Sign in with X" + is AuthProvider.Github -> "Sign in with GitHub" + is AuthProvider.Microsoft -> "Sign in with Microsoft" + is AuthProvider.Yahoo -> "Sign in with Yahoo" + is AuthProvider.Apple -> "Sign in with Apple" + is AuthProvider.Phone -> "Sign in with phone" + is AuthProvider.Anonymous -> "Continue as guest" + else -> "Continue" +} + +@Composable +private fun AuthUIAsset.asPainter() = when (this) { + is AuthUIAsset.Resource -> painterResource(resId) + is AuthUIAsset.Vector -> rememberVectorPainter(image) +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthMethodPickerUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthMethodPickerUI.kt new file mode 100644 index 000000000..fc316bde5 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthMethodPickerUI.kt @@ -0,0 +1,106 @@ +package com.firebaseui.android.demo.auth.fullcustomization.screens + +import android.util.Log +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.firebase.ui.auth.configuration.auth_provider.AuthProvider +import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState +import com.firebaseui.android.demo.auth.fullcustomization.common.OtherSignInMethodsSheet +import com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages.EmailEntryStep +import com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages.LoginStep +import com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages.SignUpStep +import com.google.firebase.auth.FirebaseAuth +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await + +private enum class FlowStep { EnterEmail, Login, SignUp } + +@Composable +fun AuthMethodPickerUI( + state: EmailAuthContentState, + otherProviders: List, + onProviderSelected: (AuthProvider) -> Unit, + tosUrl: String?, + ppUrl: String?, +) { + var flowStep by remember { mutableStateOf(FlowStep.EnterEmail) } + var showOtherMethods by remember { mutableStateOf(false) } + var isCheckingEmail by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + // Password/confirmPassword are hoisted in EmailAuthContentState, not local to LoginStep/ + // SignUpStep — they survive a round trip back to EnterEmail, so a stale password typed for + // one email could carry over if a different email also routes to the same step. Clear them + // whenever the user backs out via "Use a different email". + val onUseDifferentEmail: () -> Unit = { + state.onPasswordChange("") + state.onConfirmPasswordChange("") + flowStep = FlowStep.EnterEmail + } + + Box(modifier = Modifier.fillMaxSize()) { + when (flowStep) { + FlowStep.EnterEmail -> EmailEntryStep( + email = state.email, + onEmailChange = state.onEmailChange, + isLoading = state.isLoading || isCheckingEmail, + onContinue = { + isCheckingEmail = true + coroutineScope.launch { + val signInMethods = fetchLegacySignInMethods(state.email) + flowStep = if (signInMethods.isEmpty()) FlowStep.SignUp else FlowStep.Login + isCheckingEmail = false + } + }, + onShowOtherMethods = { showOtherMethods = true }, + ) + + FlowStep.Login -> LoginStep( + state = state, + onUseDifferentEmail = onUseDifferentEmail, + ) + + FlowStep.SignUp -> SignUpStep( + state = state, + onUseDifferentEmail = onUseDifferentEmail, + ) + } + } + + if (showOtherMethods) { + OtherSignInMethodsSheet( + otherProviders = otherProviders, + onProviderSelected = onProviderSelected, + onDismissRequest = { showOtherMethods = false }, + tosUrl = tosUrl, + ppUrl = ppUrl, + ) + } +} + +/** + * Whether [email] is already registered, via Firebase Auth's `fetchSignInMethodsForEmail` — + * deprecated by Firebase ("legacy") and, depending on the project's Email Enumeration Protection + * setting, may always return an empty list regardless of whether the email exists. + */ +private suspend fun fetchLegacySignInMethods(email: String): List { + return try { + @Suppress("DEPRECATION") + FirebaseAuth.getInstance() + .fetchSignInMethodsForEmail(email) + .await() + .signInMethods + ?.filter { it.isNotBlank() } + ?: emptyList() + } catch (e: Exception) { + Log.w("AuthMethodPickerUI", "fetchSignInMethodsForEmail failed for $email", e) + emptyList() + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthenticatedUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthenticatedUI.kt new file mode 100644 index 000000000..61fea4933 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/AuthenticatedUI.kt @@ -0,0 +1,279 @@ +package com.firebaseui.android.demo.auth.fullcustomization.screens + +import android.util.Log +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.firebase.ui.auth.AuthState +import com.firebase.ui.auth.ui.screens.AuthSuccessUiContext +import com.firebase.ui.auth.util.displayIdentifier +import com.firebase.ui.auth.util.getDisplayEmail +import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage +import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton +import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await + +private const val TAG = "FullCustomizationDemo" + +/** + * Custom UI for `FirebaseAuthScreen.authenticatedContent`. + * + * Its main job in this demo is making the other slots reachable: "Set up two-factor" navigates to + * the flow that `mfaEnrollmentContent` renders, and changing the password is a sensitive operation, + * so wrapping it in [com.firebase.ui.auth.FirebaseAuthUI.withReauth] is what provokes + * `reauthContent`. + * + * This slot also receives the email-verification and profile-completion states, which the library + * would otherwise render itself — so they are handled here too rather than falling through to a + * blank screen. + */ +@Composable +fun AuthenticatedUI(state: AuthState, uiContext: AuthSuccessUiContext) { + when (state) { + is AuthState.RequiresEmailVerification -> VerifyEmailPage(uiContext) + is AuthState.RequiresProfileCompletion -> ProfileCompletionPage(state, uiContext) + else -> SignedInPage(uiContext) + } +} + +@Composable +private fun SignedInPage(uiContext: AuthSuccessUiContext) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val authUI = uiContext.authUI + // Read on every recomposition rather than remembering: the identifier has to follow the + // current user, which changes across sign-out and reauth. + val identifier = authUI.getCurrentUser().displayIdentifier() + + var newPassword by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + var isUpdating by remember { mutableStateOf(false) } + var statusMessage by remember { mutableStateOf(null) } + var isError by remember { mutableStateOf(false) } + + AuthPage( + mascot = R.drawable.full_customization_mascot, + mascotDescription = "doggo - cute welcome mascot", + title = "You're in", + cardContentDescription = "authenticated - account card", + card = { + Text( + text = if (identifier.isNotBlank()) "Signed in as $identifier" else "Signed in", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + ) + + Text( + text = "Changing your password needs a recent sign-in, so it triggers the custom " + + "reauth screen.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + FullCustomizationTextField( + value = newPassword, + onValueChange = { + newPassword = it + statusMessage = null + }, + label = "New password", + enabled = !isUpdating, + isError = isError, + supportingText = statusMessage, + visualTransformation = if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) { + Icons.Default.VisibilityOff + } else { + Icons.Default.Visibility + }, + contentDescription = null, + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "text-field - new password secure input" }, + ) + }, + actions = { + CtaButton( + text = "Change password", + onClick = { + // lifecycleScope rather than rememberCoroutineScope: the reauth overlay + // replaces this screen mid-flight, and the retried operation has to outlive it. + lifecycleOwner.lifecycleScope.launch { + isUpdating = true + statusMessage = null + isError = false + try { + authUI.withReauth( + context, + reason = "Verify your identity to change your password", + ) { + authUI.getCurrentUser()?.updatePassword(newPassword)?.await() + Log.d(TAG, "Password changed successfully") + } + } catch (e: Exception) { + Log.e(TAG, "Password change failed", e) + isError = true + statusMessage = "Couldn't change the password. Try again." + } finally { + isUpdating = false + } + } + }, + enabled = newPassword.length >= 6 && !isUpdating, + isLoading = isUpdating, + modifier = Modifier.semantics { contentDescription = "button - change password" }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + CtaButton( + text = "Set up two-factor", + onClick = uiContext.onManageMfa, + enabled = !isUpdating, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + modifier = Modifier.semantics { contentDescription = "button - manage mfa" }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + onClick = uiContext.onSignOut, + enabled = !isUpdating, + modifier = Modifier.fillMaxWidth(), + ) { + Text(uiContext.stringProvider.signOutAction) + } + }, + ) +} + +@Composable +private fun VerifyEmailPage(uiContext: AuthSuccessUiContext) { + val stringProvider = uiContext.stringProvider + val user = uiContext.authUI.getCurrentUser() + val emailLabel = user.getDisplayEmail(stringProvider.emailProvider) + + AuthPage( + mascot = R.drawable.full_customization_mascot, + mascotDescription = "doggo - cute welcome mascot", + title = "Check your inbox", + cardContentDescription = "authenticated - verify email card", + card = { + Text( + text = stringProvider.verifyEmailInstruction(emailLabel), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + actions = { + CtaButton( + text = stringProvider.verifiedEmailAction, + onClick = uiContext.onReloadUser, + modifier = Modifier.semantics { + contentDescription = "button - recheck email verification" + }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + CtaButton( + text = stringProvider.resendVerificationEmailAction, + onClick = { user?.sendEmailVerification() }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + modifier = Modifier.semantics { + contentDescription = "button - resend verification email" + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + onClick = uiContext.onSignOut, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringProvider.signOutAction) + } + }, + ) +} + +@Composable +private fun ProfileCompletionPage( + state: AuthState.RequiresProfileCompletion, + uiContext: AuthSuccessUiContext, +) { + val stringProvider = uiContext.stringProvider + + AuthPage( + mascot = R.drawable.full_customization_mascot, + mascotDescription = "doggo - cute welcome mascot", + title = "Almost there", + cardContentDescription = "authenticated - profile completion card", + card = { + Text( + text = stringProvider.profileCompletionMessage, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (state.missingFields.isNotEmpty()) { + Text( + text = stringProvider.profileMissingFieldsMessage( + state.missingFields.joinToString() + ), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + } + }, + actions = { + TextButton( + onClick = uiContext.onSignOut, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringProvider.signOutAction) + } + }, + ) +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/EmailEntryStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/EmailEntryStep.kt new file mode 100644 index 000000000..65a933312 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/EmailEntryStep.kt @@ -0,0 +1,184 @@ +package com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +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.heightIn +import androidx.compose.foundation.layout.offset +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.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Login +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.common.AuthFieldShape +import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton +import com.firebaseui.android.demo.auth.fullcustomization.common.EmailFieldIcon +import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField +import com.firebaseui.android.demo.auth.fullcustomization.common.HardOffsetShadow +import com.firebaseui.android.demo.auth.fullcustomization.theme.IntroShape + +@Composable +fun EmailEntryStep( + email: String, + onEmailChange: (String) -> Unit, + isLoading: Boolean, + onContinue: () -> Unit, + onShowOtherMethods: () -> Unit, +) { + val isEmailValid = remember(email) { + android.util.Patterns.EMAIL_ADDRESS.matcher(email).matches() + } + val showEmailError = email.isNotBlank() && !isEmailValid + + // verticalScroll measures content with infinite max height, and Column distributes weights + // against the MIN height when max is infinite (RowColumnMeasurePolicy.kt) — so + // heightIn(min = viewport) makes the weighted spacers expand (centering content, anchoring + // the link to the bottom) when everything fits, and collapse to zero (plain scrolling) when + // it doesn't. + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight) + .padding(horizontal = 48.dp, vertical = 24.dp), + ) { + Spacer(modifier = Modifier.weight(1f)) + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(id = R.drawable.full_customization_mascot), + contentDescription = "doggo - cute welcome mascot", + modifier = Modifier + .size(96.dp) + .offset(y = 12.dp) + .zIndex(1f), + ) + + Surface( + color = MaterialTheme.colorScheme.secondary, + shape = IntroShape, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "intro - welcome headline bubble" }, + ) { + Text( + text = "Hey there,\nWelcome", + style = MaterialTheme.typography.headlineMedium.copy( + textAlign = TextAlign.Center, + brush = Brush.radialGradient( + colors = listOf( + Color(0xFFFFF8F8), + Color(0xFFFFDDB4), + Color(0xFFFFD8EB), + ), + ), + ), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 16.dp), + ) + } + + HardOffsetShadow( + shape = AuthFieldShape, + modifier = Modifier.fillMaxWidth(), + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "email - sign in card" }, + color = MaterialTheme.colorScheme.surface, + shape = AuthFieldShape, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Enter your email address to continue.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + + FullCustomizationTextField( + value = email, + onValueChange = onEmailChange, + label = "Email address", + leadingIcon = { EmailFieldIcon() }, + enabled = !isLoading, + isError = showEmailError, + supportingText = if (showEmailError) "Enter a valid email address" else null, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "text-field - email address input" }, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + CtaButton( + text = "Continue", + onClick = onContinue, + enabled = isEmailValid && !isLoading, + isLoading = isLoading, + modifier = Modifier.semantics { contentDescription = "button - continue to password" }, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + TextButton( + onClick = onShowOtherMethods, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .semantics { contentDescription = "Other sign-in methods button" }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Login, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Use other sign-in methods") + } + } + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/LoginStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/LoginStep.kt new file mode 100644 index 000000000..f26a83260 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/LoginStep.kt @@ -0,0 +1,193 @@ +package com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState +import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.common.AuthFieldShape +import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton +import com.firebaseui.android.demo.auth.fullcustomization.common.EmailFieldIcon +import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField +import com.firebaseui.android.demo.auth.fullcustomization.common.HardOffsetShadow + +@Composable +fun LoginStep( + state: EmailAuthContentState, + onUseDifferentEmail: () -> Unit, +) { + var passwordVisible by remember { mutableStateOf(false) } + + // verticalScroll measures content with infinite max height, and Column distributes weights + // against the MIN height when max is infinite (RowColumnMeasurePolicy.kt) — so + // heightIn(min = viewport) makes the weighted spacers expand (centering content, anchoring + // CTAs to the bottom) when everything fits, and collapse to zero (plain scrolling) when it + // doesn't. + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight) + .padding(horizontal = 40.dp, vertical = 24.dp), + ) { + Spacer(modifier = Modifier.weight(1f)) + + Column(modifier = Modifier.fillMaxWidth()) { + Image( + painter = painterResource(id = R.drawable.full_customization_mascot), + contentDescription = "doggo - cute welcome mascot", + modifier = Modifier.size(72.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Login", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(24.dp)) + + HardOffsetShadow(shape = AuthFieldShape, modifier = Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "email - login card" }, + color = MaterialTheme.colorScheme.surface, + shape = AuthFieldShape, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + FullCustomizationTextField( + value = state.email, + onValueChange = {}, + enabled = false, + leadingIcon = { EmailFieldIcon() }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "text-field - email address display" }, + ) + + FullCustomizationTextField( + value = state.password, + onValueChange = state.onPasswordChange, + label = "Password", + enabled = !state.isLoading, + visualTransformation = if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) { + Icons.Default.VisibilityOff + } else { + Icons.Default.Visibility + }, + contentDescription = null, + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "text-field - password secure input" }, + ) + + Text( + text = if (state.resetLinkSent) "Reset link sent!" else "Forgot password?", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + textAlign = TextAlign.End, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !state.resetLinkSent) { + state.onSendResetLinkClick() + }, + ) + } + } + } + } + + Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(24.dp)) + + Column(modifier = Modifier.fillMaxWidth()) { + CtaButton( + text = "Login", + onClick = state.onSignInClick, + enabled = state.password.isNotBlank() && !state.isLoading, + isLoading = state.isLoading, + modifier = Modifier.semantics { contentDescription = "button - login" }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + CtaButton( + text = if (state.emailSignInLinkSent) "Login link sent!" else "Send login link", + onClick = state.onSignInEmailLinkClick, + enabled = !state.isLoading, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + modifier = Modifier.semantics { contentDescription = "button - send login link" }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + onClick = onUseDifferentEmail, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Use a different email") + } + } + } + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/SignUpStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/SignUpStep.kt new file mode 100644 index 000000000..cbab9bff0 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/email/pages/SignUpStep.kt @@ -0,0 +1,229 @@ +package com.firebaseui.android.demo.auth.fullcustomization.screens.email.pages + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.ui.screens.email.EmailAuthContentState +import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.common.AuthFieldShape +import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton +import com.firebaseui.android.demo.auth.fullcustomization.common.EmailFieldIcon +import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField +import com.firebaseui.android.demo.auth.fullcustomization.common.HardOffsetShadow + +private val NameFieldStartShape = RoundedCornerShape( + topStart = 16.dp, + bottomStart = 16.dp, + topEnd = 0.dp, + bottomEnd = 0.dp, +) +private val NameFieldEndShape = RoundedCornerShape( + topStart = 0.dp, + bottomStart = 0.dp, + topEnd = 16.dp, + bottomEnd = 16.dp, +) + +@Composable +fun SignUpStep( + state: EmailAuthContentState, + onUseDifferentEmail: () -> Unit, +) { + var firstName by remember { mutableStateOf("") } + var lastName by remember { mutableStateOf("") } + var confirmEmail by remember { mutableStateOf("") } + + val emailsMatch = confirmEmail.isNotBlank() && confirmEmail == state.email + val passwordsMatch = state.confirmPassword.isNotBlank() && state.confirmPassword == state.password + val canSignUp = firstName.isNotBlank() && + lastName.isNotBlank() && + emailsMatch && + state.password.isNotBlank() && + passwordsMatch && + !state.isLoading + + // verticalScroll measures content with infinite max height, and Column distributes weights + // against the MIN height when max is infinite (RowColumnMeasurePolicy.kt) — so + // heightIn(min = viewport) makes the weighted spacers expand (centering content, anchoring + // CTAs to the bottom) when everything fits, and collapse to zero (plain scrolling) when it + // doesn't. + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight) + .padding(horizontal = 40.dp, vertical = 24.dp), + ) { + Spacer(modifier = Modifier.weight(1f)) + + Column(modifier = Modifier.fillMaxWidth()) { + Image( + painter = painterResource(id = R.drawable.full_customization_mascot), + contentDescription = "doggo - cute welcome mascot", + modifier = Modifier.size(72.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Sign up", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(24.dp)) + + HardOffsetShadow(shape = AuthFieldShape, modifier = Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "sign up card" }, + color = MaterialTheme.colorScheme.surface, + shape = AuthFieldShape, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + Row(modifier = Modifier.fillMaxWidth()) { + FullCustomizationTextField( + value = firstName, + onValueChange = { firstName = it }, + label = "First name", + enabled = !state.isLoading, + shape = NameFieldStartShape, + modifier = Modifier + .weight(1f) + .semantics { contentDescription = "text-field - first name" }, + ) + FullCustomizationTextField( + value = lastName, + onValueChange = { lastName = it }, + label = "Last name", + enabled = !state.isLoading, + shape = NameFieldEndShape, + modifier = Modifier + .weight(1f) + .semantics { contentDescription = "text-field - last name" }, + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + FullCustomizationTextField( + value = state.email, + onValueChange = {}, + enabled = false, + leadingIcon = { EmailFieldIcon() }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "text-field - email address display" }, + ) + FullCustomizationTextField( + value = confirmEmail, + onValueChange = { confirmEmail = it }, + label = "Confirm Email", + enabled = !state.isLoading, + isError = confirmEmail.isNotBlank() && !emailsMatch, + supportingText = if (confirmEmail.isNotBlank() && !emailsMatch) { + "Emails don't match" + } else { + null + }, + leadingIcon = { EmailFieldIcon() }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "text-field - confirm email" }, + ) + } + + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + FullCustomizationTextField( + value = state.password, + onValueChange = state.onPasswordChange, + label = "Password", + enabled = !state.isLoading, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "text-field - password" }, + ) + FullCustomizationTextField( + value = state.confirmPassword, + onValueChange = state.onConfirmPasswordChange, + label = "Confirm Password", + enabled = !state.isLoading, + visualTransformation = PasswordVisualTransformation(), + isError = state.confirmPassword.isNotBlank() && !passwordsMatch, + supportingText = if (state.confirmPassword.isNotBlank() && !passwordsMatch) { + "Passwords don't match" + } else { + null + }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "text-field - confirm password" }, + ) + } + } + } + } + } + + Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(24.dp)) + + Column(modifier = Modifier.fillMaxWidth()) { + CtaButton( + text = "Sign up", + onClick = { + state.onDisplayNameChange("$firstName $lastName".trim()) + state.onSignUpClick() + }, + enabled = canSignUp, + isLoading = state.isLoading, + modifier = Modifier.semantics { contentDescription = "button - sign up" }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + onClick = onUseDifferentEmail, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Use a different email") + } + } + } + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/MfaChallengeUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/MfaChallengeUI.kt new file mode 100644 index 000000000..741f41882 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/MfaChallengeUI.kt @@ -0,0 +1,101 @@ +package com.firebaseui.android.demo.auth.fullcustomization.screens.mfa + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.configuration.MfaFactor +import com.firebase.ui.auth.mfa.MfaChallengeContentState +import com.firebase.ui.auth.ui.components.VerificationCodeInputField +import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage +import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton + +/** + * Custom UI for `FirebaseAuthScreen.mfaChallengeContent` — the second-factor prompt shown during + * sign-in when the account has MFA enrolled. + */ +@Composable +fun MfaChallengeUI(state: MfaChallengeContentState) { + val isSms = state.factorType == MfaFactor.Sms + + AuthPage( + mascot = if (isSms) { + R.drawable.full_customization_phone_mascot + } else { + R.drawable.full_customization_mascot + }, + mascotDescription = "doggo - cute two-factor mascot", + title = "One more step", + cardContentDescription = "mfa - challenge card", + card = { + Text( + text = if (isSms) { + "We sent a code to ${state.maskedPhoneNumber ?: "your phone"}." + } else { + "Open your authenticator app and enter the 6-digit code for this account." + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + VerificationCodeInputField( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "text-field - mfa challenge code input" }, + isError = state.hasError, + errorMessage = state.error, + onCodeChange = state.onVerificationCodeChange, + ) + + // canResend already covers "SMS factor and a resend callback exists". + if (state.canResend) { + Text( + text = if (state.resendTimer > 0) { + "Resend code in ${state.resendTimer}s" + } else { + "Resend code" + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.End, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = state.resendTimer == 0 && !state.isLoading) { + state.onResendCodeClick?.invoke() + }, + ) + } + }, + actions = { + CtaButton( + text = "Verify", + onClick = state.onVerifyClick, + enabled = state.isValid && !state.isLoading, + isLoading = state.isLoading, + modifier = Modifier.semantics { + contentDescription = "button - verify mfa challenge" + }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + onClick = state.onCancelClick, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Cancel") + } + }, + ) +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/MfaEnrollmentUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/MfaEnrollmentUI.kt new file mode 100644 index 000000000..59024f3bc --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/MfaEnrollmentUI.kt @@ -0,0 +1,27 @@ +package com.firebaseui.android.demo.auth.fullcustomization.screens.mfa + +import androidx.compose.runtime.Composable +import com.firebase.ui.auth.mfa.MfaEnrollmentContentState +import com.firebase.ui.auth.mfa.MfaEnrollmentStep +import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages.ConfigureSmsStep +import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages.ConfigureTotpStep +import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages.RecoveryCodesStep +import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages.SelectFactorStep +import com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages.VerifyFactorStep + +/** + * Custom UI for `FirebaseAuthScreen.mfaEnrollmentContent`. + * + * A single state object drives all five enrollment steps, so this only dispatches on + * [MfaEnrollmentContentState.step] — the library owns the step transitions. + */ +@Composable +fun MfaEnrollmentUI(state: MfaEnrollmentContentState) { + when (state.step) { + MfaEnrollmentStep.SelectFactor -> SelectFactorStep(state) + MfaEnrollmentStep.ConfigureSms -> ConfigureSmsStep(state) + MfaEnrollmentStep.ConfigureTotp -> ConfigureTotpStep(state) + MfaEnrollmentStep.VerifyFactor -> VerifyFactorStep(state) + MfaEnrollmentStep.ShowRecoveryCodes -> RecoveryCodesStep(state) + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureSmsStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureSmsStep.kt new file mode 100644 index 000000000..de9a85cab --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureSmsStep.kt @@ -0,0 +1,124 @@ +package com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Phone +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.mfa.MfaEnrollmentContentState +import com.firebase.ui.auth.ui.components.CountrySelector +import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.common.AuthFieldShape +import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage +import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton +import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField + +@Composable +fun ConfigureSmsStep(state: MfaEnrollmentContentState) { + AuthPage( + mascot = R.drawable.full_customization_phone_mascot, + mascotDescription = "doggo - cute phone sign-in mascot", + title = "Add your number", + cardContentDescription = "mfa - sms setup card", + card = { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth(), + ) { + // CountrySelector needs a non-null country; the library's own default UI skips the + // whole step while the country is still resolving, so match that. + state.selectedCountry?.let { country -> + Surface( + color = Color.White, + shape = AuthFieldShape, + modifier = Modifier + .requiredHeight(56.dp) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + shape = AuthFieldShape, + ) + .semantics { contentDescription = "country code selector" }, + ) { + CountrySelector( + selectedCountry = country, + onCountrySelected = state.onCountrySelected, + enabled = !state.isLoading, + ) + } + } + + FullCustomizationTextField( + value = state.phoneNumber, + onValueChange = state.onPhoneNumberChange, + placeholder = "Phone number", + leadingIcon = { + Icon( + imageVector = Icons.Default.Phone, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + enabled = !state.isLoading, + isError = state.hasError, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + modifier = Modifier + .weight(1f) + .semantics { contentDescription = "text-field - mfa phone number input" }, + ) + } + + Text( + text = state.error + ?: "We'll text a code to this number whenever you sign in. " + + "Message & data rates may apply.", + style = MaterialTheme.typography.bodyLarge, + color = if (state.hasError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + }, + actions = { + CtaButton( + text = "Send code", + onClick = state.onSendSmsCodeClick, + enabled = state.isValid && !state.isLoading, + isLoading = state.isLoading, + modifier = Modifier.semantics { + contentDescription = "button - send mfa sms code" + }, + ) + + if (state.canGoBack) { + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + onClick = state.onBackClick, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Pick a different method") + } + } + }, + ) +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureTotpStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureTotpStep.kt new file mode 100644 index 000000000..562fe7919 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/ConfigureTotpStep.kt @@ -0,0 +1,102 @@ +package com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.mfa.MfaEnrollmentContentState +import com.firebase.ui.auth.ui.components.QrCodeImage +import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.common.AuthFieldShape +import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage +import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton + +@Composable +fun ConfigureTotpStep(state: MfaEnrollmentContentState) { + AuthPage( + mascot = R.drawable.full_customization_mascot, + mascotDescription = "doggo - cute security mascot", + title = "Scan to set up", + cardContentDescription = "mfa - totp setup card", + card = { + Text( + text = "Scan this with your authenticator app, or type the key in by hand.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + state.totpQrCodeUrl?.let { url -> + QrCodeImage( + content = url, + size = 200.dp, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + shape = AuthFieldShape, + ) + .padding(12.dp) + .semantics { contentDescription = "mfa - totp qr code" }, + ) + } + + state.totpSecret?.sharedSecretKey?.let { key -> + SelectionContainer { + Text( + text = key, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "mfa - totp shared secret key" }, + ) + } + } + + state.error?.let { error -> + Text( + text = error, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + } + }, + actions = { + CtaButton( + text = "I've added it", + onClick = state.onContinueToVerifyClick, + enabled = state.isValid && !state.isLoading, + isLoading = state.isLoading, + modifier = Modifier.semantics { + contentDescription = "button - continue to mfa verification" + }, + ) + + if (state.canGoBack) { + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + onClick = state.onBackClick, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Pick a different method") + } + } + }, + ) +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/RecoveryCodesStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/RecoveryCodesStep.kt new file mode 100644 index 000000000..b05722aec --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/RecoveryCodesStep.kt @@ -0,0 +1,66 @@ +package com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.mfa.MfaEnrollmentContentState +import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage +import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton + +@Composable +fun RecoveryCodesStep(state: MfaEnrollmentContentState) { + AuthPage( + mascot = R.drawable.full_customization_mascot, + mascotDescription = "doggo - cute security mascot", + title = "Save these codes", + cardContentDescription = "mfa - recovery codes card", + card = { + Text( + text = "Keep these somewhere safe. Each one gets you back in once if you lose " + + "your second factor.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + + SelectionContainer { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "mfa - recovery code list" }, + ) { + state.recoveryCodes.orEmpty().forEach { code -> + Text( + text = code, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + }, + actions = { + CtaButton( + text = "I've saved them", + onClick = state.onCodesSavedClick, + enabled = state.isValid && !state.isLoading, + isLoading = state.isLoading, + modifier = Modifier.semantics { + contentDescription = "button - confirm recovery codes saved" + }, + ) + }, + ) +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/SelectFactorStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/SelectFactorStep.kt new file mode 100644 index 000000000..676ad465c --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/SelectFactorStep.kt @@ -0,0 +1,142 @@ +package com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.configuration.MfaFactor +import com.firebase.ui.auth.mfa.MfaEnrollmentContentState +import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage +import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton +import com.google.firebase.auth.MultiFactorInfo +import com.google.firebase.auth.PhoneMultiFactorGenerator +import com.google.firebase.auth.TotpMultiFactorGenerator + +@Composable +fun SelectFactorStep(state: MfaEnrollmentContentState) { + AuthPage( + mascot = R.drawable.full_customization_mascot, + mascotDescription = "doggo - cute security mascot", + title = "Secure your account", + cardContentDescription = "mfa - factor selection card", + card = { + Text( + text = "Add a second step to sign-in, so a password on its own isn't enough.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + state.error?.let { error -> + Text( + text = error, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + } + + if (state.enrolledFactors.isNotEmpty()) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "Already on this account", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + ) + + state.enrolledFactors.forEach { info -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = enrolledFactorLabel(info), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + TextButton( + onClick = { state.onUnenrollFactor(info) }, + enabled = !state.isLoading, + modifier = Modifier.semantics { + contentDescription = + "button - remove factor ${enrolledFactorLabel(info)}" + }, + ) { + Text("Remove") + } + } + } + } + } + }, + actions = { + state.availableFactors.forEachIndexed { index, factor -> + if (index > 0) Spacer(modifier = Modifier.height(16.dp)) + + CtaButton( + text = factorCtaLabel(factor), + onClick = { state.onFactorSelected(factor) }, + enabled = !state.isLoading, + // The first factor carries the primary CTA colour; the rest read as + // alternatives, matching how LoginStep tiers its two CTAs. + colors = if (index == 0) { + ButtonDefaults.buttonColors() + } else { + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + }, + modifier = Modifier.semantics { + contentDescription = "button - enroll ${factorCtaLabel(factor)}" + }, + ) + } + + state.onSkipClick?.let { onSkip -> + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + onClick = onSkip, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Not now") + } + } + }, + ) +} + +private fun factorCtaLabel(factor: MfaFactor): String = when (factor) { + MfaFactor.Sms -> "Use text message" + MfaFactor.Totp -> "Use an authenticator app" +} + +/** + * SMS factors carry the phone number as their display name; TOTP factors are often unnamed, so + * fall back to the factor id. + */ +private fun enrolledFactorLabel(info: MultiFactorInfo): String { + val fallback = when (info.factorId) { + PhoneMultiFactorGenerator.FACTOR_ID -> "Text message" + TotpMultiFactorGenerator.FACTOR_ID -> "Authenticator app" + else -> info.factorId + } + return info.displayName?.takeIf { it.isNotBlank() } ?: fallback +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/VerifyFactorStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/VerifyFactorStep.kt new file mode 100644 index 000000000..351c73cfc --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/mfa/pages/VerifyFactorStep.kt @@ -0,0 +1,100 @@ +package com.firebaseui.android.demo.auth.fullcustomization.screens.mfa.pages + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.configuration.MfaFactor +import com.firebase.ui.auth.mfa.MfaEnrollmentContentState +import com.firebase.ui.auth.ui.components.VerificationCodeInputField +import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage +import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton + +@Composable +fun VerifyFactorStep(state: MfaEnrollmentContentState) { + val isSms = state.selectedFactor == MfaFactor.Sms + val fullPhoneNumber = "${state.selectedCountry?.dialCode ?: ""}${state.phoneNumber}" + + AuthPage( + mascot = if (isSms) { + R.drawable.full_customization_phone_mascot + } else { + R.drawable.full_customization_mascot + }, + mascotDescription = "doggo - cute two-factor mascot", + title = "Confirm the code", + cardContentDescription = "mfa - enrollment verification card", + card = { + Text( + text = if (isSms) { + "We sent a code to $fullPhoneNumber." + } else { + "Enter the 6-digit code your authenticator app is showing right now." + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + VerificationCodeInputField( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "text-field - mfa enrollment code input" }, + isError = state.hasError, + errorMessage = state.error, + onCodeChange = state.onVerificationCodeChange, + ) + + // onResendCodeClick is null for TOTP, where there is nothing to resend. + state.onResendCodeClick?.let { onResend -> + Text( + text = if (state.resendTimer > 0) { + "Resend code in ${state.resendTimer}s" + } else { + "Resend code" + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.End, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = state.resendTimer == 0 && !state.isLoading) { + onResend() + }, + ) + } + }, + actions = { + CtaButton( + text = "Verify", + onClick = state.onVerifyClick, + enabled = state.isValid && !state.isLoading, + isLoading = state.isLoading, + modifier = Modifier.semantics { + contentDescription = "button - verify mfa enrollment" + }, + ) + + if (state.canGoBack) { + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + onClick = state.onBackClick, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Back") + } + } + }, + ) +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/PhoneSignInUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/PhoneSignInUI.kt new file mode 100644 index 000000000..ecf4bc41a --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/PhoneSignInUI.kt @@ -0,0 +1,30 @@ +package com.firebaseui.android.demo.auth.fullcustomization.screens.phone + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState +import com.firebase.ui.auth.ui.screens.phone.PhoneAuthStep +import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.screens.phone.pages.PhoneEntryStep +import com.firebaseui.android.demo.auth.fullcustomization.screens.phone.pages.PhoneVerificationStep + +@Composable +fun PhoneSignInUI(state: PhoneAuthContentState) { + Box(modifier = Modifier.fillMaxSize()) { + Image( + painter = painterResource(id = R.drawable.custom_background), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + when (state.step) { + PhoneAuthStep.EnterPhoneNumber -> PhoneEntryStep(state) + PhoneAuthStep.EnterVerificationCode -> PhoneVerificationStep(state) + } + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneEntryStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneEntryStep.kt new file mode 100644 index 000000000..38289f1d1 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneEntryStep.kt @@ -0,0 +1,166 @@ +package com.firebaseui.android.demo.auth.fullcustomization.screens.phone.pages + +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Phone +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.ui.components.CountrySelector +import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState +import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.common.AuthFieldShape +import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton +import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField +import com.firebaseui.android.demo.auth.fullcustomization.common.HardOffsetShadow + +@Composable +fun PhoneEntryStep(state: PhoneAuthContentState) { + val isPhoneValid = remember(state.phoneNumber) { + android.util.Patterns.PHONE.matcher(state.phoneNumber).matches() + } + + // verticalScroll measures content with infinite max height, and Column distributes weights + // against the MIN height when max is infinite (RowColumnMeasurePolicy.kt) — so + // heightIn(min = viewport) makes the weighted spacers expand (centering content, anchoring + // the CTA to the bottom) when everything fits, and collapse to zero (plain scrolling) when it + // doesn't. + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight) + .padding(horizontal = 40.dp, vertical = 24.dp), + ) { + Spacer(modifier = Modifier.weight(1f)) + + Column(modifier = Modifier.fillMaxWidth()) { + Image( + painter = painterResource(id = R.drawable.full_customization_phone_mascot), + contentDescription = "doggo - cute phone sign-in mascot", + modifier = Modifier.size(72.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Login by phone number", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(24.dp)) + + HardOffsetShadow(shape = AuthFieldShape, modifier = Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "phone - sign in card" }, + color = MaterialTheme.colorScheme.surface, + shape = AuthFieldShape, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Surface( + color = Color.White, + shape = AuthFieldShape, + modifier = Modifier + .requiredHeight(56.dp) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + shape = AuthFieldShape, + ) + .semantics { contentDescription = "country code selector" }, + ) { + CountrySelector( + selectedCountry = state.selectedCountry, + onCountrySelected = state.onCountrySelected, + enabled = !state.isLoading, + ) + } + + FullCustomizationTextField( + value = state.phoneNumber, + onValueChange = state.onPhoneNumberChange, + placeholder = "Phone number", + leadingIcon = { + Icon( + imageVector = Icons.Default.Phone, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + enabled = !state.isLoading, + isError = state.error != null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + modifier = Modifier + .weight(1f) + .semantics { contentDescription = "text-field - phone number input" }, + ) + } + + Text( + text = state.error + ?: "By signing in with phone number, an SMS may be sent. " + + "Message & data rates may apply.", + style = MaterialTheme.typography.bodyLarge, + color = if (state.error != null) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + } + + Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(24.dp)) + + CtaButton( + text = "Sign Up", + onClick = state.onSendCodeClick, + enabled = isPhoneValid && !state.isLoading, + isLoading = state.isLoading, + modifier = Modifier.semantics { contentDescription = "button - send verification code" }, + ) + } + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneVerificationStep.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneVerificationStep.kt new file mode 100644 index 000000000..8e9a15318 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/phone/pages/PhoneVerificationStep.kt @@ -0,0 +1,137 @@ +package com.firebaseui.android.demo.auth.fullcustomization.screens.phone.pages + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.ui.components.VerificationCodeInputField +import com.firebase.ui.auth.ui.screens.phone.PhoneAuthContentState +import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.common.AuthFieldShape +import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton +import com.firebaseui.android.demo.auth.fullcustomization.common.HardOffsetShadow + +@Composable +fun PhoneVerificationStep(state: PhoneAuthContentState) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight) + .padding(horizontal = 40.dp, vertical = 24.dp), + ) { + Spacer(modifier = Modifier.weight(1f)) + + Column(modifier = Modifier.fillMaxWidth()) { + Image( + painter = painterResource(id = R.drawable.full_customization_phone_mascot), + contentDescription = "doggo - cute phone sign-in mascot", + modifier = Modifier.size(72.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Enter your code", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(24.dp)) + + HardOffsetShadow(shape = AuthFieldShape, modifier = Modifier.fillMaxWidth()) { + Surface( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "phone - verification card" }, + color = MaterialTheme.colorScheme.surface, + shape = AuthFieldShape, + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "We sent a code to ${state.fullPhoneNumber}.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + VerificationCodeInputField( + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "text-field - verification code input" }, + isError = state.error != null, + errorMessage = state.error, + onCodeChange = state.onVerificationCodeChange, + ) + + Text( + text = if (state.resendTimer > 0) { + "Resend code in ${state.resendTimer}s" + } else { + "Resend code" + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.End, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = state.resendTimer == 0) { + state.onResendCodeClick() + }, + ) + } + } + } + } + + Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(24.dp)) + + Column(modifier = Modifier.fillMaxWidth()) { + CtaButton( + text = "Verify", + onClick = state.onVerifyCodeClick, + enabled = state.verificationCode.isNotBlank() && !state.isLoading, + isLoading = state.isLoading, + modifier = Modifier.semantics { contentDescription = "button - verify code" }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + onClick = state.onChangeNumberClick, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Use a different number") + } + } + } + } +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/reauth/ReauthUI.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/reauth/ReauthUI.kt new file mode 100644 index 000000000..2784597ca --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/screens/reauth/ReauthUI.kt @@ -0,0 +1,203 @@ +package com.firebaseui.android.demo.auth.fullcustomization.screens.reauth + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.firebase.ui.auth.AuthState +import com.firebaseui.android.demo.R +import com.firebaseui.android.demo.auth.fullcustomization.common.AuthPage +import com.firebaseui.android.demo.auth.fullcustomization.common.CtaButton +import com.firebaseui.android.demo.auth.fullcustomization.common.EmailFieldIcon +import com.firebaseui.android.demo.auth.fullcustomization.common.FullCustomizationTextField +import com.google.firebase.auth.EmailAuthProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await + +/** + * Custom UI for `FirebaseAuthScreen.reauthContent`. + * + * Unlike the MFA slots, this one is handed only the [AuthState.ReauthenticationRequired] state — + * no input values and no verify callback — so the re-authentication itself happens here. Providing + * this slot also opts out of the library's default reauth bottom sheet, which is what would + * otherwise drive the credential flow. + * + * @param onVerified invoked once the credential is accepted. The caller is responsible for + * dismissing this UI and running [AuthState.ReauthenticationRequired.retryOperation] from a scope + * that outlives this composable — see `FullCustomizationDemoActivity`. + */ +@Composable +fun ReauthUI( + state: AuthState.ReauthenticationRequired, + onDismiss: () -> Unit, + onVerified: () -> Unit, +) { + val email = state.user.email.orEmpty() + val hasPasswordProvider = remember(state.user) { + state.user.providerData.any { it.providerId == EmailAuthProvider.PROVIDER_ID } + } + + if (!hasPasswordProvider) { + UnsupportedProviderPage(reason = state.reason, onDismiss = onDismiss) + return + } + + var password by remember { mutableStateOf("") } + var passwordVisible by remember { mutableStateOf(false) } + var isVerifying by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() + + AuthPage( + mascot = R.drawable.full_customization_mascot, + mascotDescription = "doggo - cute welcome mascot", + title = "Is that you?", + cardContentDescription = "reauth - password card", + card = { + Text( + text = state.reason ?: "Confirm your password to continue.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + FullCustomizationTextField( + value = email, + onValueChange = {}, + enabled = false, + leadingIcon = { EmailFieldIcon() }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "text-field - reauth email display" }, + ) + + FullCustomizationTextField( + value = password, + onValueChange = { + password = it + errorMessage = null + }, + label = "Password", + enabled = !isVerifying, + isError = errorMessage != null, + supportingText = errorMessage, + visualTransformation = if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = if (passwordVisible) { + Icons.Default.VisibilityOff + } else { + Icons.Default.Visibility + }, + contentDescription = null, + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "text-field - reauth password secure input" }, + ) + }, + actions = { + CtaButton( + text = "Verify", + onClick = { + coroutineScope.launch { + isVerifying = true + errorMessage = null + try { + state.user + .reauthenticate( + EmailAuthProvider.getCredential(email, password) + ) + .await() + // Neither call suspends, so cancellation can't interleave once + // onVerified() tears this composable down. + onVerified() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + errorMessage = e.localizedMessage + ?: "We couldn't verify that password." + isVerifying = false + } + } + }, + enabled = password.isNotBlank() && !isVerifying, + isLoading = isVerifying, + modifier = Modifier.semantics { contentDescription = "button - verify reauth" }, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + TextButton( + onClick = onDismiss, + enabled = !isVerifying, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Cancel") + } + }, + ) +} + +/** + * The library only routes here when at least one configured provider is linked to the user, so a + * federated-only account still reaches this slot — it just can't be re-verified by password. + */ +@Composable +private fun UnsupportedProviderPage(reason: String?, onDismiss: () -> Unit) { + AuthPage( + mascot = R.drawable.full_customization_mascot, + mascotDescription = "doggo - cute welcome mascot", + title = "Is that you?", + cardContentDescription = "reauth - unsupported provider card", + card = { + reason?.let { + Text( + text = it, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + text = "This account signs in with a provider rather than a password, so this " + + "demo screen can't re-verify it. Sign out and back in to continue.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + }, + actions = { + CtaButton( + text = "Close", + onClick = onDismiss, + modifier = Modifier.semantics { contentDescription = "button - dismiss reauth" }, + ) + }, + ) +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationShapes.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationShapes.kt new file mode 100644 index 000000000..483b0b741 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationShapes.kt @@ -0,0 +1,8 @@ +package com.firebaseui.android.demo.auth.fullcustomization.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.unit.dp + +val IntroShape = RoundedCornerShape(80.dp) +val ButtonShape = RoundedCornerShape(36.dp) +val ProviderButtonShape = RoundedCornerShape(percent = 50) diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTheme.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTheme.kt new file mode 100644 index 000000000..53b916119 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTheme.kt @@ -0,0 +1,135 @@ +package com.firebaseui.android.demo.auth.fullcustomization.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.firebase.ui.auth.configuration.theme.AuthUITheme +import kotlin.math.max +import kotlin.math.min + +private val LightPrimary = Color(0xFF864B6F) +private val LightOnPrimary = Color(0xFFFFFFFF) +private val LightPrimaryContainer = Color(0xFFFFD8EB) +private val LightOnPrimaryContainer = Color(0xFF7B3B73) +private val LightInversePrimary = Color(0xFFFAB1DA) +private val LightSecondary = Color(0xFF4C8BFF) +private val LightOnSecondary = Color(0xFFFFFFFF) +private val LightSecondaryContainer = Color(0xFFCCE5FF) +private val LightTertiaryContainer = Color(0xFFFFDDB4) +private val LightSurface = Color(0xFFFFF8F8) +private val LightSurfaceBright = Color(0xFFFFF8F8) +private val LightOnSurface = Color(0xFF211A1D) +private val LightOnSurfaceVariant = Color(0xFFA08B95) +private val LightSurfaceContainer = Color(0xFFF9EAEF) +private val LightSurfaceContainerLow = Color(0xFFFDF0F6) +private val LightOutline = Color(0xFF81737A) +private val LightOutlineVariant = Color(0xFFD3C2C9) +private val LightInverseSurface = Color(0xFF322F35) +private val LightInverseOnSurface = Color(0xFFF5EFF7) + +val FullCustomizationLightColorScheme = lightColorScheme( + primary = LightPrimary, + onPrimary = LightOnPrimary, + primaryContainer = LightPrimaryContainer, + onPrimaryContainer = LightOnPrimaryContainer, + inversePrimary = LightInversePrimary, + secondary = LightSecondary, + onSecondary = LightOnSecondary, + secondaryContainer = LightSecondaryContainer, + tertiaryContainer = LightTertiaryContainer, + surface = LightSurface, + surfaceBright = LightSurfaceBright, + onSurface = LightOnSurface, + onSurfaceVariant = LightOnSurfaceVariant, + surfaceContainer = LightSurfaceContainer, + surfaceContainerLow = LightSurfaceContainerLow, + outline = LightOutline, + outlineVariant = LightOutlineVariant, + inverseSurface = LightInverseSurface, + inverseOnSurface = LightInverseOnSurface, +) + +val FullCustomizationDarkColorScheme = darkColorScheme( + primary = LightPrimary.withLightness(0.78f), + onPrimary = LightOnPrimary.withLightness(0.18f), + primaryContainer = LightPrimaryContainer.withLightness(0.28f), + onPrimaryContainer = LightOnPrimaryContainer.withLightness(0.88f), + inversePrimary = LightPrimary, + secondary = LightSecondary.withLightness(0.78f), + onSecondary = LightOnSecondary.withLightness(0.18f), + secondaryContainer = LightSecondaryContainer.withLightness(0.28f), + tertiaryContainer = LightTertiaryContainer.withLightness(0.28f), + surface = LightSurface.withLightness(0.10f), + surfaceBright = LightSurfaceBright.withLightness(0.20f), + onSurface = LightOnSurface.withLightness(0.88f), + onSurfaceVariant = LightOnSurfaceVariant.withLightness(0.75f), + surfaceContainer = LightSurfaceContainer.withLightness(0.13f), + surfaceContainerLow = LightSurfaceContainerLow.withLightness(0.11f), + outline = LightOutline.withLightness(0.55f), + outlineVariant = LightOutlineVariant.withLightness(0.30f), + inverseSurface = LightSurface.withLightness(0.90f), + inverseOnSurface = LightOnSurface.withLightness(0.15f), +) + +@Composable +fun FullCustomizationTheme(content: @Composable () -> Unit) { + val colorScheme = if (isSystemInDarkTheme()) { + FullCustomizationDarkColorScheme + } else { + FullCustomizationLightColorScheme + } + AuthUITheme( + theme = AuthUITheme.Default.copy( + colorScheme = colorScheme, + typography = FullCustomizationTypography, + providerButtonShape = ProviderButtonShape, + ), + content = content, + ) +} + +private fun Color.withLightness(newLightness: Float): Color { + val (h, s, _) = toHsl() + return hslToColor(h, s, newLightness.coerceIn(0f, 1f), alpha) +} + +private fun Color.toHsl(): Triple { + val r = red + val g = green + val b = blue + val maxC = max(r, max(g, b)) + val minC = min(r, min(g, b)) + val l = (maxC + minC) / 2f + if (maxC == minC) return Triple(0f, 0f, l) + val d = maxC - minC + val s = if (l > 0.5f) d / (2f - maxC - minC) else d / (maxC + minC) + val h = when (maxC) { + r -> ((g - b) / d + (if (g < b) 6f else 0f)) + g -> ((b - r) / d + 2f) + else -> ((r - g) / d + 4f) + } / 6f + return Triple(h, s, l) +} + +private fun hslToColor(h: Float, s: Float, l: Float, alpha: Float): Color { + if (s == 0f) return Color(l, l, l, alpha) + fun hueToRgb(p: Float, q: Float, tIn: Float): Float { + var t = tIn + if (t < 0f) t += 1f + if (t > 1f) t -= 1f + return when { + t < 1f / 6f -> p + (q - p) * 6f * t + t < 1f / 2f -> q + t < 2f / 3f -> p + (q - p) * (2f / 3f - t) * 6f + else -> p + } + } + val q = if (l < 0.5f) l * (1f + s) else l + s - l * s + val p = 2f * l - q + val r = hueToRgb(p, q, h + 1f / 3f) + val g = hueToRgb(p, q, h) + val b = hueToRgb(p, q, h - 1f / 3f) + return Color(r, g, b, alpha) +} diff --git a/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTypography.kt b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTypography.kt new file mode 100644 index 000000000..da31b8bc2 --- /dev/null +++ b/app/src/main/java/com/firebaseui/android/demo/auth/fullcustomization/theme/FullCustomizationTypography.kt @@ -0,0 +1,59 @@ +package com.firebaseui.android.demo.auth.fullcustomization.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.firebaseui.android.demo.R + +val BagelFatOne = FontFamily(Font(R.font.bagel_fat_one_regular, FontWeight.Normal)) + +val Onest = FontFamily( + Font(R.font.onest_regular, FontWeight.Normal), + Font(R.font.onest_medium, FontWeight.Medium), + Font(R.font.onest_semibold, FontWeight.SemiBold), + Font(R.font.onest_bold, FontWeight.Bold), +) + +val Roboto = FontFamily( + Font(R.font.roboto_regular, FontWeight.Normal), + Font(R.font.roboto_medium, FontWeight.Medium), + Font(R.font.roboto_semibold, FontWeight.SemiBold), + Font(R.font.roboto_bold, FontWeight.Bold), +) + +val FullCustomizationTypography = Typography( + headlineSmall = TextStyle( + fontFamily = BagelFatOne, + fontWeight = FontWeight.Normal, + fontSize = 28.sp, + lineHeight = 36.sp, + ), + headlineMedium = TextStyle( + fontFamily = BagelFatOne, + fontWeight = FontWeight.Normal, + fontSize = 36.sp, + lineHeight = 44.sp, + ), + bodyLarge = TextStyle( + fontFamily = Onest, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + ), + labelLarge = TextStyle( + fontFamily = Roboto, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + titleMedium = TextStyle( + fontFamily = Onest, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + lineHeight = 20.sp, + ), +) diff --git a/app/src/main/res/drawable-xhdpi/email_at_sign.png b/app/src/main/res/drawable-xhdpi/email_at_sign.png new file mode 100644 index 000000000..f7082d7ad Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/email_at_sign.png differ diff --git a/app/src/main/res/drawable-xhdpi/full_customization_mascot.png b/app/src/main/res/drawable-xhdpi/full_customization_mascot.png new file mode 100644 index 000000000..0a5c3afa3 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/full_customization_mascot.png differ diff --git a/app/src/main/res/drawable-xhdpi/full_customization_phone_mascot.png b/app/src/main/res/drawable-xhdpi/full_customization_phone_mascot.png new file mode 100644 index 000000000..6dee4a852 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/full_customization_phone_mascot.png differ diff --git a/app/src/main/res/drawable/custom_background.png b/app/src/main/res/drawable/custom_background.png new file mode 100644 index 000000000..ce5dfe236 Binary files /dev/null and b/app/src/main/res/drawable/custom_background.png differ diff --git a/app/src/main/res/font/bagel_fat_one_regular.ttf b/app/src/main/res/font/bagel_fat_one_regular.ttf new file mode 100644 index 000000000..9de4a2f78 Binary files /dev/null and b/app/src/main/res/font/bagel_fat_one_regular.ttf differ diff --git a/app/src/main/res/font/onest_bold.ttf b/app/src/main/res/font/onest_bold.ttf new file mode 100644 index 000000000..b0a3dd939 Binary files /dev/null and b/app/src/main/res/font/onest_bold.ttf differ diff --git a/app/src/main/res/font/onest_medium.ttf b/app/src/main/res/font/onest_medium.ttf new file mode 100644 index 000000000..2ff600481 Binary files /dev/null and b/app/src/main/res/font/onest_medium.ttf differ diff --git a/app/src/main/res/font/onest_regular.ttf b/app/src/main/res/font/onest_regular.ttf new file mode 100644 index 000000000..dec9f7a23 Binary files /dev/null and b/app/src/main/res/font/onest_regular.ttf differ diff --git a/app/src/main/res/font/onest_semibold.ttf b/app/src/main/res/font/onest_semibold.ttf new file mode 100644 index 000000000..c7e8a3d2e Binary files /dev/null and b/app/src/main/res/font/onest_semibold.ttf differ diff --git a/app/src/main/res/font/roboto_bold.ttf b/app/src/main/res/font/roboto_bold.ttf new file mode 100644 index 000000000..651618564 Binary files /dev/null and b/app/src/main/res/font/roboto_bold.ttf differ diff --git a/app/src/main/res/font/roboto_medium.ttf b/app/src/main/res/font/roboto_medium.ttf new file mode 100644 index 000000000..bc5b17026 Binary files /dev/null and b/app/src/main/res/font/roboto_medium.ttf differ diff --git a/app/src/main/res/font/roboto_regular.ttf b/app/src/main/res/font/roboto_regular.ttf new file mode 100644 index 000000000..3db0d1fb0 Binary files /dev/null and b/app/src/main/res/font/roboto_regular.ttf differ diff --git a/app/src/main/res/font/roboto_semibold.ttf b/app/src/main/res/font/roboto_semibold.ttf new file mode 100644 index 000000000..7a8ef87d5 Binary files /dev/null and b/app/src/main/res/font/roboto_semibold.ttf differ diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt index 65ea606dd..1027b9cab 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProvider+FirebaseAuthUI.kt @@ -1,7 +1,6 @@ package com.firebase.ui.auth.configuration.auth_provider import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState @@ -14,27 +13,32 @@ import kotlinx.coroutines.tasks.await /** * Creates a remembered launcher function for anonymous sign-in. * + * @param config Authentication UI configuration + * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure * @return A launcher function that starts the anonymous sign-in flow when invoked * * @see signInAnonymously * @see createOrLinkUserWithEmailAndPassword for upgrading anonymous accounts */ @Composable -internal fun FirebaseAuthUI.rememberAnonymousSignInHandler(config: AuthUIConfiguration): () -> Unit { +internal fun FirebaseAuthUI.rememberAnonymousSignInHandler( + config: AuthUIConfiguration, + onSignInFailure: (AuthException) -> Unit = {}, +): () -> Unit { val context = androidx.compose.ui.platform.LocalContext.current val coroutineScope = rememberCoroutineScope() - return remember(this) { - { - coroutineScope.launch { - try { - signInAnonymously(config) - } catch (e: AuthException) { - // Already an AuthException, don't re-wrap it - updateAuthState(AuthState.Error(e)) - } catch (e: Exception) { - val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) - } + return { + coroutineScope.launch { + try { + signInAnonymously(config) + } catch (e: AuthException) { + // Already an AuthException, don't re-wrap it + updateAuthState(AuthState.Error(e)) + if (e !is AuthException.AuthCancelledException) onSignInFailure(e) + } catch (e: Exception) { + val authException = AuthException.from(e, context) + updateAuthState(AuthState.Error(authException)) + if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException) } } } diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt index d6a9622e7..6cefb834a 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/FacebookAuthProvider+FirebaseAuthUI.kt @@ -19,8 +19,10 @@ import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import com.facebook.AccessToken import com.facebook.CallbackManager import com.facebook.FacebookCallback @@ -47,6 +49,7 @@ import kotlinx.coroutines.launch * @param config The [AuthUIConfiguration] containing authentication settings * @param provider The [AuthProvider.Facebook] configuration with scopes and credential provider * @param loginManagerProvider Provides logout operations to clear stale Facebook sessions + * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure * * @return A launcher function that starts the Facebook sign-in flow when invoked * @@ -58,10 +61,15 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( config: AuthUIConfiguration, provider: AuthProvider.Facebook, loginManagerProvider: AuthProvider.Facebook.LoginManagerProvider = AuthProvider.Facebook.DefaultLoginManagerProvider(), + onSignInFailure: (AuthException) -> Unit = {}, ): () -> Unit { val coroutineScope = rememberCoroutineScope() val callbackManager = remember { CallbackManager.Factory.create() } val loginManager = LoginManager.getInstance() + val currentContext by rememberUpdatedState(context) + val currentConfig by rememberUpdatedState(config) + val currentProvider by rememberUpdatedState(provider) + val currentOnSignInFailure by rememberUpdatedState(onSignInFailure) val launcher = rememberLauncherForActivityResult( loginManager.createLogInActivityResultContract( @@ -71,7 +79,7 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( onResult = {}, ) - DisposableEffect(config) { + DisposableEffect(Unit) { loginManager.registerCallback( callbackManager, object : FacebookCallback { @@ -79,17 +87,19 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( coroutineScope.launch { try { signInWithFacebook( - context = context, - config = config, - provider = provider, + context = currentContext, + config = currentConfig, + provider = currentProvider, accessToken = result.accessToken, ) } catch (e: AuthException) { // Already an AuthException, don't re-wrap it updateAuthState(AuthState.Error(e)) + if (e !is AuthException.AuthCancelledException) currentOnSignInFailure(e) } catch (e: Exception) { - val authException = AuthException.from(e, context) + val authException = AuthException.from(e, currentContext) updateAuthState(AuthState.Error(authException)) + if (authException !is AuthException.AuthCancelledException) currentOnSignInFailure(authException) } } } @@ -100,12 +110,13 @@ internal fun FirebaseAuthUI.rememberSignInWithFacebookLauncher( override fun onError(error: FacebookException) { Log.e("FacebookAuthProvider", "Error during Facebook sign in", error) - val authException = AuthException.from(error, context) + val authException = AuthException.from(error, currentContext) updateAuthState( AuthState.Error( authException ) ) + currentOnSignInFailure(authException) } }) diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt index 139eab163..b46f184fb 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProvider+FirebaseAuthUI.kt @@ -3,7 +3,6 @@ package com.firebase.ui.auth.configuration.auth_provider import android.content.Context import android.util.Log import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.credentials.CredentialManager import androidx.credentials.exceptions.GetCredentialCancellationException @@ -48,6 +47,7 @@ import kotlinx.coroutines.launch * @param context Android context for Credential Manager * @param config Authentication UI configuration * @param provider Google provider configuration with server client ID and optional scopes + * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure * @return A callback function that initiates Google Sign-In when invoked * * @see signInWithGoogle @@ -58,19 +58,20 @@ internal fun FirebaseAuthUI.rememberGoogleSignInHandler( context: Context, config: AuthUIConfiguration, provider: AuthProvider.Google, + onSignInFailure: (AuthException) -> Unit = {}, ): () -> Unit { val coroutineScope = rememberCoroutineScope() - return remember(this, config) { - { - coroutineScope.launch { - try { - signInWithGoogle(context, config, provider) - } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) - } catch (e: Exception) { - val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) - } + return { + coroutineScope.launch { + try { + signInWithGoogle(context, config, provider) + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + if (e !is AuthException.AuthCancelledException) onSignInFailure(e) + } catch (e: Exception) { + val authException = AuthException.from(e, context) + updateAuthState(AuthState.Error(authException)) + if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException) } } } @@ -161,6 +162,21 @@ internal suspend fun FirebaseAuthUI.signInWithGoogle( autoSelectEnabled = provider.autoSelectEnabled ) } catch (fallbackException: NoCredentialException) { + // Credential Manager doesn't distinguish "no account on device" from + // developer-side misconfiguration, so log the possible causes for + // debugging. Never surfaced to end users: the overwhelming majority + // hitting this genuinely have no account, and Firebase Console + // guidance would just confuse them. + Log.w( + "GoogleAuthProvider", + "No credential returned from Credential Manager after trying both " + + "authorized and all accounts. Possible causes: (1) no Google " + + "account on this device, (2) no Android OAuth client / SHA-1 " + + "registered for this app's package + signing certificate in the " + + "Firebase console, or (3) the Credential Manager Google ID " + + "provider is unavailable on this device.", + fallbackException + ) // No Google accounts available on device at all throw AuthException.UnknownException( message = "No Google accounts available.\n\nPlease add a Google account to your device and try again.", diff --git a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt index add6bb235..58d35cea0 100644 --- a/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/configuration/auth_provider/OAuthProvider+FirebaseAuthUI.kt @@ -3,7 +3,6 @@ package com.firebase.ui.auth.configuration.auth_provider import android.app.Activity import android.content.Context import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState @@ -41,6 +40,7 @@ import kotlinx.coroutines.tasks.await * * @param config Authentication UI configuration * @param provider OAuth provider configuration + * @param onSignInFailure Callback invoked with the resulting [AuthException] on failure * * @return Lambda that triggers OAuth sign-in when invoked * @@ -54,6 +54,7 @@ internal fun FirebaseAuthUI.rememberOAuthSignInHandler( activity: Activity?, config: AuthUIConfiguration, provider: AuthProvider.OAuth, + onSignInFailure: (AuthException) -> Unit = {}, ): () -> Unit { val coroutineScope = rememberCoroutineScope() activity ?: throw IllegalStateException( @@ -61,22 +62,22 @@ internal fun FirebaseAuthUI.rememberOAuthSignInHandler( "Ensure FirebaseAuthScreen is used within an Activity." ) - return remember(this, provider.providerId, config) { - { - coroutineScope.launch { - try { - signInWithProvider( - context = context, - config = config, - activity = activity, - provider = provider - ) - } catch (e: AuthException) { - updateAuthState(AuthState.Error(e)) - } catch (e: Exception) { - val authException = AuthException.from(e, context) - updateAuthState(AuthState.Error(authException)) - } + return { + coroutineScope.launch { + try { + signInWithProvider( + context = context, + config = config, + activity = activity, + provider = provider + ) + } catch (e: AuthException) { + updateAuthState(AuthState.Error(e)) + if (e !is AuthException.AuthCancelledException) onSignInFailure(e) + } catch (e: Exception) { + val authException = AuthException.from(e, context) + updateAuthState(AuthState.Error(authException)) + if (authException !is AuthException.AuthCancelledException) onSignInFailure(authException) } } } diff --git a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt index 852fd10cd..6b8b6c51d 100644 --- a/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt +++ b/auth/src/main/java/com/firebase/ui/auth/ui/screens/FirebaseAuthScreen.kt @@ -190,6 +190,7 @@ fun FirebaseAuthScreen( ) ) }, + onSignInFailure = onSignInFailure, ) val continueWithProvider: (String) -> Unit = { providerId -> configuration.providers.find { it.providerId == providerId }?.let { onProviderSelected(it) } @@ -222,6 +223,8 @@ fun FirebaseAuthScreen( ) { composable(AuthRoute.MethodPicker.route) { if (customMethodPickerLayout != null) { + // Takes over the entire screen — no logo, no ToS/Privacy footer, and no + // automatic inset handling. See the KDoc on customMethodPickerLayout. Box(modifier = modifier.fillMaxSize()) { customMethodPickerLayout(configuration.providers, onProviderSelected) } @@ -1033,6 +1036,7 @@ private fun FirebaseAuthUI.rememberOnProviderSelected( config: AuthUIConfiguration, onNavigate: (AuthRoute) -> Unit, onUnknownProvider: ((AuthProvider) -> Unit)? = null, + onSignInFailure: (AuthException) -> Unit = {}, ): (AuthProvider) -> Unit { val anonymousProvider = config.providers.filterIsInstance().firstOrNull() val googleProvider = config.providers.filterIsInstance().firstOrNull() @@ -1044,16 +1048,18 @@ private fun FirebaseAuthUI.rememberOnProviderSelected( val twitterProvider = config.providers.filterIsInstance().firstOrNull() val genericOAuthProviders = config.providers.filterIsInstance() - val onSignInAnonymously = anonymousProvider?.let { rememberAnonymousSignInHandler(config) } - val onSignInWithGoogle = googleProvider?.let { rememberGoogleSignInHandler(context, config, it) } - val onSignInWithFacebook = facebookProvider?.let { rememberSignInWithFacebookLauncher(context, config, it) } - val onSignInWithApple = appleProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } - val onSignInWithGithub = githubProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } - val onSignInWithMicrosoft = microsoftProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } - val onSignInWithYahoo = yahooProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } - val onSignInWithTwitter = twitterProvider?.let { rememberOAuthSignInHandler(context, activity, config, it) } + val onSignInAnonymously = anonymousProvider?.let { rememberAnonymousSignInHandler(config, onSignInFailure) } + val onSignInWithGoogle = googleProvider?.let { rememberGoogleSignInHandler(context, config, it, onSignInFailure) } + val onSignInWithFacebook = facebookProvider?.let { + rememberSignInWithFacebookLauncher(context, config, it, onSignInFailure = onSignInFailure) + } + val onSignInWithApple = appleProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } + val onSignInWithGithub = githubProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } + val onSignInWithMicrosoft = microsoftProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } + val onSignInWithYahoo = yahooProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } + val onSignInWithTwitter = twitterProvider?.let { rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } val genericOAuthHandlers = genericOAuthProviders.associateWith { - rememberOAuthSignInHandler(context, activity, config, it) + rememberOAuthSignInHandler(context, activity, config, it, onSignInFailure) } return { provider -> diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt index 6e71c4170..18f396f9b 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/AnonymousAuthProviderFirebaseAuthUITest.kt @@ -15,6 +15,7 @@ package com.firebase.ui.auth.configuration.auth_provider import android.content.Context +import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState @@ -38,6 +39,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatchers @@ -54,6 +56,9 @@ import org.robolectric.annotation.Config @Config(manifest = Config.NONE) class AnonymousAuthProviderFirebaseAuthUITest { + @get:Rule + val composeTestRule = createComposeRule() + @Mock private lateinit var mockFirebaseAuth: FirebaseAuth @@ -214,6 +219,35 @@ class AnonymousAuthProviderFirebaseAuthUITest { assertThat(errorState.exception).isInstanceOf(AuthException.UnknownException::class.java) } + // ============================================================================================= + // rememberAnonymousSignInHandler - onSignInFailure reporting + // ============================================================================================= + + @Test + fun `rememberAnonymousSignInHandler reports failure via onSignInFailure immediately, at the source`() { + val networkException = FirebaseNetworkException("Network error") + val taskCompletionSource = TaskCompletionSource() + taskCompletionSource.setException(networkException) + `when`(mockFirebaseAuth.signInAnonymously()).thenReturn(taskCompletionSource.task) + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val reportedFailures = mutableListOf() + var launcher: (() -> Unit)? = null + + composeTestRule.setContent { + launcher = instance.rememberAnonymousSignInHandler( + config = config, + onSignInFailure = { reportedFailures.add(it) }, + ) + } + + composeTestRule.runOnIdle { launcher?.invoke() } + composeTestRule.waitForIdle() + + assertThat(reportedFailures).hasSize(1) + assertThat(reportedFailures.single()).isInstanceOf(AuthException.NetworkException::class.java) + } + // ============================================================================================= // Anonymous Account Upgrade Tests // ============================================================================================= diff --git a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt index 312de80cf..bba4f8c50 100644 --- a/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/configuration/auth_provider/GoogleAuthProviderFirebaseAuthUITest.kt @@ -15,9 +15,12 @@ package com.firebase.ui.auth.configuration.auth_provider import android.content.Context +import android.util.Log +import androidx.compose.ui.test.junit4.createComposeRule import androidx.core.net.toUri import androidx.credentials.CredentialManager import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.NoCredentialException import androidx.test.core.app.ApplicationProvider import com.firebase.ui.auth.AuthException import com.firebase.ui.auth.AuthState @@ -38,6 +41,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock @@ -53,6 +57,7 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLog /** * Comprehensive unit tests for Google Sign-In provider methods in FirebaseAuthUI. @@ -70,6 +75,9 @@ import org.robolectric.annotation.Config @Config(manifest = Config.NONE) class GoogleAuthProviderFirebaseAuthUITest { + @get:Rule + val composeTestRule = createComposeRule() + @Mock private lateinit var mockFirebaseAuth: FirebaseAuth @@ -440,6 +448,65 @@ class GoogleAuthProviderFirebaseAuthUITest { assertThat(errorState.exception).isInstanceOf(AuthException.UnknownException::class.java) } + @Test + fun `Sign in with Google when both credential attempts throw NoCredentialException logs diagnostic warning`() = runTest { + ShadowLog.clear() + val noCredentialException = NoCredentialException("No credential available") + + `when`( + mockCredentialManagerProvider.getGoogleCredential( + context = eq(applicationContext), + credentialManager = any(), + serverClientId = eq("test-client-id"), + filterByAuthorizedAccounts = eq(true), + autoSelectEnabled = eq(false) + ) + ).thenAnswer { throw noCredentialException } + + `when`( + mockCredentialManagerProvider.getGoogleCredential( + context = eq(applicationContext), + credentialManager = any(), + serverClientId = eq("test-client-id"), + filterByAuthorizedAccounts = eq(false), + autoSelectEnabled = eq(false) + ) + ).thenAnswer { throw noCredentialException } + + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val googleProvider = AuthProvider.Google( + serverClientId = "test-client-id", + scopes = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(googleProvider) + } + } + + try { + instance.signInWithGoogle( + context = applicationContext, + config = config, + provider = googleProvider, + authorizationProvider = mockAuthorizationProvider, + credentialManagerProvider = mockCredentialManagerProvider + ) + throw AssertionError("Expected exception to be thrown") + } catch (e: AuthException) { + // User-facing message stays generic - never mentions Firebase Console/SHA-1 + assertThat(e).isInstanceOf(AuthException.UnknownException::class.java) + assertThat(e.message).contains("No Google accounts available") + } + + // Diagnostic detail goes to Logcat only, for developers + val diagnosticLog = ShadowLog.getLogs().firstOrNull { + it.type == Log.WARN && it.tag == "GoogleAuthProvider" && it.msg.contains("SHA-1") + } + assertThat(diagnosticLog).isNotNull() + } + @Test fun `Sign in with Google when Firebase sign-in fails should throw AuthException`() = runTest { val mockCredential = mock(AuthCredential::class.java) @@ -963,4 +1030,59 @@ class GoogleAuthProviderFirebaseAuthUITest { val finalState = instance.authStateFlow().first { it !is AuthState.Loading } assertThat(finalState).isEqualTo(AuthState.Success(result = mockAuthResult, user = mockUser, isNewUser = false)) } + + // ============================================================================================= + // rememberGoogleSignInHandler - onSignInFailure reporting + // ============================================================================================= + + @Test + fun `rememberGoogleSignInHandler reports failure via onSignInFailure immediately, at the source`() { + val instance = FirebaseAuthUI.create(firebaseApp, mockFirebaseAuth) + val googleProvider = AuthProvider.Google( + serverClientId = "test-client-id", + scopes = emptyList() + ) + val config = authUIConfiguration { + context = applicationContext + providers { + provider(googleProvider) + } + } + + // A picker-level failure that used to never reach onSignInFailure at all: + // the outer fallback throws AuthException.UnknownException when no Google accounts are found. + instance.testCredentialManagerProvider = object : AuthProvider.Google.CredentialManagerProvider { + override suspend fun getGoogleCredential( + context: Context, + credentialManager: CredentialManager, + serverClientId: String, + filterByAuthorizedAccounts: Boolean, + autoSelectEnabled: Boolean + ): AuthProvider.Google.GoogleSignInResult { + throw AuthException.UnknownException( + "No Google accounts available.\n\nPlease add a Google account to your device and try again." + ) + } + + override suspend fun clearCredentialState(context: Context, credentialManager: CredentialManager) = Unit + } + + val reportedFailures = mutableListOf() + var launcher: (() -> Unit)? = null + + composeTestRule.setContent { + launcher = instance.rememberGoogleSignInHandler( + context = applicationContext, + config = config, + provider = googleProvider, + onSignInFailure = { reportedFailures.add(it) }, + ) + } + + composeTestRule.runOnIdle { launcher?.invoke() } + composeTestRule.waitForIdle() + + assertThat(reportedFailures).hasSize(1) + assertThat(reportedFailures.single()).isInstanceOf(AuthException.UnknownException::class.java) + } } diff --git a/e2eTest/src/test/java/com/firebase/ui/auth/testutil/EmulatorApi.kt b/e2eTest/src/test/java/com/firebase/ui/auth/testutil/EmulatorApi.kt index 4430c37a7..6a2c60e8a 100644 --- a/e2eTest/src/test/java/com/firebase/ui/auth/testutil/EmulatorApi.kt +++ b/e2eTest/src/test/java/com/firebase/ui/auth/testutil/EmulatorApi.kt @@ -18,23 +18,48 @@ class EmulatorAuthApi( * This function calls the emulator's clear data endpoint to remove all accounts, * OOB codes, and other authentication data. This ensures test isolation by providing * a clean slate for each test. + * + * Retries on transient failures (e.g. a loaded CI runner momentarily failing to respond) + * and throws if the emulator still can't be cleared, so a broken reset fails the test + * loudly instead of silently leaking stale accounts into the next test. */ fun clearEmulatorData() { - try { - clearAccounts() - } catch (e: Exception) { - println("WARNING: Exception while clearing emulator data: ${e.message}") + val maxRetries = 3 + var lastError: Exception? = null + for (attempt in 1..maxRetries) { + try { + clearAccounts() + return + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw e + } catch (e: Exception) { + lastError = e + println("WARNING: Failed to clear emulator data (attempt $attempt/$maxRetries): ${e.message}") + if (attempt < maxRetries) { + try { + Thread.sleep(500L * attempt) + } catch (ie: InterruptedException) { + Thread.currentThread().interrupt() + throw ie + } + } + } } + throw IllegalStateException( + "Failed to clear Firebase Auth Emulator data after $maxRetries attempts. " + + "Aborting test to avoid running against stale emulator state.", + lastError + ) } fun clearAccounts() { httpClient.delete("/emulator/v1/projects/$projectId/accounts") { connection -> val responseCode = connection.responseCode if (responseCode !in 200..299) { - println("WARNING: Failed to clear emulator data: HTTP $responseCode") - } else { - println("TEST: Cleared emulator data") + throw IllegalStateException("Failed to clear emulator data: HTTP $responseCode") } + println("TEST: Cleared emulator data") } } diff --git a/storage/build.gradle.kts b/storage/build.gradle.kts index 7ac13aeba..d3cfc2d40 100644 --- a/storage/build.gradle.kts +++ b/storage/build.gradle.kts @@ -57,4 +57,4 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.mockito.core) -} \ No newline at end of file +}