fix: extract Firebase auth calls into AuthRepository — LoginViewModel no longer depends on Firebase directly

This commit is contained in:
null 2026-06-17 23:02:25 -05:00
parent 370a56069f
commit 8217a3385d
7 changed files with 106 additions and 0 deletions

View File

@ -118,6 +118,11 @@ dependencies {
// RevenueCat native Android SDK (group: com.revenuecat.purchases, artifact: purchases) // RevenueCat native Android SDK (group: com.revenuecat.purchases, artifact: purchases)
implementation("com.revenuecat.purchases:purchases:8.20.0") implementation("com.revenuecat.purchases:purchases:8.20.0")
// Google Sign-In via Credential Manager
implementation("androidx.credentials:credentials:1.3.0")
implementation("androidx.credentials:credentials-play-services-auth:1.3.0")
implementation("com.google.android.libraries.identity.googleid:googleid:1.1.1")
// Coroutines // Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")

View File

@ -2,6 +2,7 @@ package app.closer.data.remote
import app.closer.domain.model.AuthState import app.closer.domain.model.AuthState
import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.GoogleAuthProvider
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
@ -64,6 +65,14 @@ class FirebaseAuthDataSource @Inject constructor() {
.addOnFailureListener { cont.resumeWithException(it) } .addOnFailureListener { cont.resumeWithException(it) }
} }
suspend fun signInWithGoogle(idToken: String): String =
suspendCancellableCoroutine { cont ->
val credential = GoogleAuthProvider.getCredential(idToken, null)
auth.signInWithCredential(credential)
.addOnSuccessListener { cont.resume(it.user?.uid ?: "") }
.addOnFailureListener { cont.resumeWithException(it) }
}
fun signOut() = auth.signOut() fun signOut() = auth.signOut()
suspend fun deleteAccount(): Unit = suspend fun deleteAccount(): Unit =

View File

@ -19,6 +19,11 @@ class FirebaseAuthRepositoryImpl @Inject constructor(
override val currentUserEmail: String? get() = dataSource.currentUserEmail override val currentUserEmail: String? get() = dataSource.currentUserEmail
override val isSignedIn: Boolean get() = dataSource.isSignedIn override val isSignedIn: Boolean get() = dataSource.isSignedIn
override suspend fun signInWithGoogle(idToken: String): Result<String> =
withRateLimit(AuthRateLimiter.Flow.LOGIN) {
runCatching { dataSource.signInWithGoogle(idToken) }
}
override suspend fun signInAnonymously(): Result<String> = override suspend fun signInAnonymously(): Result<String> =
withRateLimit(AuthRateLimiter.Flow.ANONYMOUS) { withRateLimit(AuthRateLimiter.Flow.ANONYMOUS) {
runCatching { dataSource.signInAnonymously() } runCatching { dataSource.signInAnonymously() }

View File

@ -8,6 +8,7 @@ interface AuthRepository {
val currentUserId: String? val currentUserId: String?
val currentUserEmail: String? val currentUserEmail: String?
val isSignedIn: Boolean val isSignedIn: Boolean
suspend fun signInWithGoogle(idToken: String): Result<String>
suspend fun signInAnonymously(): Result<String> suspend fun signInAnonymously(): Result<String>
suspend fun signInWithEmail(email: String, password: String): Result<String> suspend fun signInWithEmail(email: String, password: String): Result<String>
suspend fun signUpWithEmail(email: String, password: String): Result<String> suspend fun signUpWithEmail(email: String, password: String): Result<String>

View File

@ -1,11 +1,24 @@
package app.closer.ui.auth package app.closer.ui.auth
import app.closer.ui.theme.closerCardColor import app.closer.ui.theme.closerCardColor
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.BackgroundColor import app.closer.ui.theme.BackgroundColor
import app.closer.ui.theme.OnBackgroundColor import app.closer.ui.theme.OnBackgroundColor
@ -13,6 +26,9 @@ import app.closer.ui.theme.OnPrimaryColor
import app.closer.ui.theme.OnSurfaceVariantColor import app.closer.ui.theme.OnSurfaceVariantColor
import app.closer.ui.theme.PrimaryColor import app.closer.ui.theme.PrimaryColor
internal const val GOOGLE_WEB_CLIENT_ID =
"556235913214-l3risvbo7ouv80e22cojblufhjchgn1a.apps.googleusercontent.com"
internal val AuthBackgroundBrush: Brush internal val AuthBackgroundBrush: Brush
get() = Brush.linearGradient( get() = Brush.linearGradient(
colors = listOf(BackgroundColor, CloserPalette.BackgroundWash, CloserPalette.PinkMist), colors = listOf(BackgroundColor, CloserPalette.BackgroundWash, CloserPalette.PinkMist),
@ -26,6 +42,38 @@ internal val AuthPrimary = PrimaryColor
internal val AuthPrimaryDeep = CloserPalette.PurpleDeep internal val AuthPrimaryDeep = CloserPalette.PurpleDeep
internal val AuthOnPrimary = OnPrimaryColor internal val AuthOnPrimary = OnPrimaryColor
@Composable
internal fun GoogleSignInButton(
onClick: () -> Unit,
enabled: Boolean = true,
modifier: Modifier = Modifier
) {
OutlinedButton(
onClick = onClick,
enabled = enabled,
modifier = modifier.fillMaxWidth().height(52.dp),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = Color.White,
contentColor = Color(0xFF1F1F1F)
),
border = BorderStroke(1.dp, Color(0xFFDADCE0))
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "G",
style = MaterialTheme.typography.labelLarge.copy(
fontWeight = FontWeight.ExtraBold,
color = Color(0xFF4285F4)
)
)
Text("Continue with Google", style = MaterialTheme.typography.labelLarge)
}
}
}
@Composable @Composable
internal fun authTextFieldColors() = OutlinedTextFieldDefaults.colors( internal fun authTextFieldColors() = OutlinedTextFieldDefaults.colors(
focusedBorderColor = AuthPrimaryDeep, focusedBorderColor = AuthPrimaryDeep,

View File

@ -3,6 +3,11 @@ package app.closer.ui.auth
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.credentials.CredentialManager
import androidx.credentials.GetCredentialRequest
import androidx.credentials.exceptions.GetCredentialCancellationException
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@ -36,6 +41,9 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.launch
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
@ -58,6 +66,8 @@ fun LoginScreen(
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val snackbar = remember { SnackbarHostState() } val snackbar = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(state.success) { LaunchedEffect(state.success) {
if (state.success) onNavigate(AppRoute.HOME) if (state.success) onNavigate(AppRoute.HOME)
@ -168,6 +178,28 @@ fun LoginScreen(
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
GoogleSignInButton(
enabled = !state.isLoading,
onClick = {
scope.launch {
try {
val credMgr = CredentialManager.create(context)
val option = GetSignInWithGoogleOption.Builder(GOOGLE_WEB_CLIENT_ID).build()
val request = GetCredentialRequest.Builder().addCredentialOption(option).build()
val result = credMgr.getCredential(context, request)
val idToken = GoogleIdTokenCredential.createFrom(result.credential.data).idToken
viewModel.signInWithGoogle(idToken)
} catch (_: GetCredentialCancellationException) {
// user dismissed — do nothing
} catch (e: Exception) {
viewModel.reportError("Google sign-in failed. Please try again.")
}
}
}
)
Spacer(Modifier.height(12.dp))
OutlinedButton( OutlinedButton(
onClick = viewModel::signInAnonymously, onClick = viewModel::signInAnonymously,
enabled = !state.isLoading, enabled = !state.isLoading,

View File

@ -42,10 +42,16 @@ class LoginViewModel @Inject constructor(
attemptSignIn { authRepository.signInWithEmail(state.email.trim(), state.password) } attemptSignIn { authRepository.signInWithEmail(state.email.trim(), state.password) }
} }
fun signInWithGoogle(idToken: String) {
attemptSignIn { authRepository.signInWithGoogle(idToken) }
}
fun signInAnonymously() { fun signInAnonymously() {
attemptSignIn { authRepository.signInAnonymously() } attemptSignIn { authRepository.signInAnonymously() }
} }
fun reportError(message: String) = _uiState.update { it.copy(error = message) }
private fun attemptSignIn(action: suspend () -> Result<String>) { private fun attemptSignIn(action: suspend () -> Result<String>) {
_uiState.update { it.copy(isLoading = true, error = null) } _uiState.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch { viewModelScope.launch {