diff --git a/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt b/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt index b934c6ee..235bb2ba 100644 --- a/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirebaseAuthDataSource.kt @@ -23,6 +23,7 @@ class FirebaseAuthDataSource @Inject constructor() { val currentUserEmail: String? get() = auth.currentUser?.email val isSignedIn: Boolean get() = auth.currentUser != null val isAnonymous: Boolean get() = auth.currentUser?.isAnonymous ?: false + val isEmailVerified: Boolean get() = auth.currentUser?.isEmailVerified ?: false val isGoogleAccount: Boolean get() = auth.currentUser?.providerData?.any { it.providerId == GoogleAuthProvider.PROVIDER_ID } == true @@ -42,13 +43,6 @@ class FirebaseAuthDataSource @Inject constructor() { auth.currentUser?.let { AuthState.Authenticated(it.uid, it.isAnonymous) } ?: AuthState.Unauthenticated - suspend fun signInAnonymously(): String = - suspendCancellableCoroutine { cont -> - auth.signInAnonymously() - .addOnSuccessListener { cont.resume(it.user?.uid ?: "") } - .addOnFailureListener { cont.resumeWithException(it) } - } - suspend fun signInWithEmail(email: String, password: String): String = suspendCancellableCoroutine { cont -> auth.signInWithEmailAndPassword(email, password) @@ -63,6 +57,31 @@ class FirebaseAuthDataSource @Inject constructor() { .addOnFailureListener { cont.resumeWithException(it) } } + suspend fun sendEmailVerification(): Unit = + suspendCancellableCoroutine { cont -> + val user = auth.currentUser + if (user == null) { + cont.resumeWithException(IllegalStateException("No signed-in user")) + return@suspendCancellableCoroutine + } + user.sendEmailVerification() + .addOnSuccessListener { cont.resume(Unit) } + .addOnFailureListener { cont.resumeWithException(it) } + } + + /** Refreshes the cached FirebaseUser so [isEmailVerified] reflects server state. */ + suspend fun reloadUser(): Unit = + suspendCancellableCoroutine { cont -> + val user = auth.currentUser + if (user == null) { + cont.resumeWithException(IllegalStateException("No signed-in user")) + return@suspendCancellableCoroutine + } + user.reload() + .addOnSuccessListener { cont.resume(Unit) } + .addOnFailureListener { cont.resumeWithException(it) } + } + suspend fun sendPasswordResetEmail(email: String): Unit = suspendCancellableCoroutine { cont -> auth.sendPasswordResetEmail(email) diff --git a/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt index 79aa06af..ab6a0221 100644 --- a/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/FirebaseAuthRepositoryImpl.kt @@ -21,17 +21,13 @@ class FirebaseAuthRepositoryImpl @Inject constructor( override val isSignedIn: Boolean get() = dataSource.isSignedIn override val isAnonymous: Boolean get() = dataSource.isAnonymous override val isGoogleAccount: Boolean get() = dataSource.isGoogleAccount + override val isEmailVerified: Boolean get() = dataSource.isEmailVerified override suspend fun signInWithGoogle(idToken: String): Result = withRateLimit(AuthRateLimiter.Flow.LOGIN) { runCatching { dataSource.signInWithGoogle(idToken) } } - override suspend fun signInAnonymously(): Result = - withRateLimit(AuthRateLimiter.Flow.ANONYMOUS) { - runCatching { dataSource.signInAnonymously() } - } - override suspend fun signInWithEmail(email: String, password: String): Result = withRateLimit(AuthRateLimiter.Flow.LOGIN) { runCatching { dataSource.signInWithEmail(email, password) } @@ -42,6 +38,12 @@ class FirebaseAuthRepositoryImpl @Inject constructor( runCatching { dataSource.signUpWithEmail(email, password) } } + override suspend fun sendEmailVerification(): Result = + runCatching { dataSource.sendEmailVerification() } + + override suspend fun reloadUser(): Result = + runCatching { dataSource.reloadUser() } + override suspend fun sendPasswordResetEmail(email: String): Result = withRateLimit(AuthRateLimiter.Flow.PASSWORD_RESET) { runCatching { dataSource.sendPasswordResetEmail(email) } diff --git a/app/src/main/java/app/closer/domain/repository/AuthRepository.kt b/app/src/main/java/app/closer/domain/repository/AuthRepository.kt index 6de94077..74f1d975 100644 --- a/app/src/main/java/app/closer/domain/repository/AuthRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/AuthRepository.kt @@ -11,10 +11,12 @@ interface AuthRepository { val isSignedIn: Boolean val isAnonymous: Boolean val isGoogleAccount: Boolean + val isEmailVerified: Boolean suspend fun signInWithGoogle(idToken: String): Result - suspend fun signInAnonymously(): Result suspend fun signInWithEmail(email: String, password: String): Result suspend fun signUpWithEmail(email: String, password: String): Result + suspend fun sendEmailVerification(): Result + suspend fun reloadUser(): Result suspend fun sendPasswordResetEmail(email: String): Result suspend fun signOut() suspend fun reauthenticateWithEmail(email: String, password: String): Result diff --git a/app/src/main/java/app/closer/ui/auth/LoginScreen.kt b/app/src/main/java/app/closer/ui/auth/LoginScreen.kt index 411d5ab0..97dc1017 100644 --- a/app/src/main/java/app/closer/ui/auth/LoginScreen.kt +++ b/app/src/main/java/app/closer/ui/auth/LoginScreen.kt @@ -2,7 +2,6 @@ package app.closer.ui.auth import app.closer.R import androidx.compose.foundation.background -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.credentials.CredentialManager import androidx.credentials.CustomCredential @@ -31,7 +30,6 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -213,21 +211,6 @@ fun LoginScreen( } ) - Spacer(Modifier.height(12.dp)) - - OutlinedButton( - onClick = viewModel::signInAnonymously, - enabled = !state.isLoading, - modifier = Modifier.fillMaxWidth().height(52.dp), - colors = ButtonDefaults.outlinedButtonColors( - containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.62f), - contentColor = AuthPrimaryDeep - ), - border = BorderStroke(1.dp, AuthPrimaryDeep.copy(alpha = 0.28f)) - ) { - Text("Try without account", style = MaterialTheme.typography.labelLarge) - } - Spacer(Modifier.height(28.dp)) TextButton(onClick = { onNavigate(AppRoute.SIGN_UP) }) { diff --git a/app/src/main/java/app/closer/ui/auth/LoginViewModel.kt b/app/src/main/java/app/closer/ui/auth/LoginViewModel.kt index f7b55200..678142e4 100644 --- a/app/src/main/java/app/closer/ui/auth/LoginViewModel.kt +++ b/app/src/main/java/app/closer/ui/auth/LoginViewModel.kt @@ -58,10 +58,6 @@ class LoginViewModel @Inject constructor( } } - fun signInAnonymously() { - attemptSignIn { authRepository.signInAnonymously() } - } - fun reportError(message: String) = _uiState.update { it.copy(error = message) } private fun attemptSignIn(action: suspend () -> Result) { diff --git a/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt b/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt index a085d8e5..5abc0031 100644 --- a/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt +++ b/app/src/main/java/app/closer/ui/auth/SignUpScreen.kt @@ -149,7 +149,7 @@ fun SignUpScreen( }, supportingText = { Text( - "At least 6 characters", + "At least 8 characters, with letters and numbers", style = MaterialTheme.typography.bodySmall, color = AuthMuted ) diff --git a/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt b/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt index 7c29df86..2ad4d2f0 100644 --- a/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt +++ b/app/src/main/java/app/closer/ui/auth/SignUpViewModel.kt @@ -37,15 +37,21 @@ class SignUpViewModel @Inject constructor( fun signUp() { val state = _uiState.value + val pw = state.password when { state.email.isBlank() -> { _uiState.update { it.copy(error = "Please enter your email.") }; return } - state.password.length < 6 -> { _uiState.update { it.copy(error = "Password must be at least 6 characters.") }; return } - state.password != state.confirmPassword -> { _uiState.update { it.copy(error = "Passwords don't match.") }; return } + pw.length < 8 -> { _uiState.update { it.copy(error = "Password must be at least 8 characters.") }; return } + !pw.any { it.isLetter() } || !pw.any { it.isDigit() } -> { _uiState.update { it.copy(error = "Password must include both letters and numbers.") }; return } + pw != state.confirmPassword -> { _uiState.update { it.copy(error = "Passwords don't match.") }; return } } _uiState.update { it.copy(isLoading = true, error = null) } viewModelScope.launch { - authRepository.signUpWithEmail(state.email.trim(), state.password) - .onSuccess { _uiState.update { it.copy(isLoading = false, success = true) } } + authRepository.signUpWithEmail(state.email.trim(), pw) + .onSuccess { + // Best-effort: send a verification email. Don't block account creation if it fails. + authRepository.sendEmailVerification() + _uiState.update { it.copy(isLoading = false, success = true) } + } .onFailure { e -> val msg = friendlyError(e) _uiState.update { it.copy(isLoading = false, error = msg) }