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)
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
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 com.google.firebase.auth.FirebaseAuth
import com.google.firebase.auth.GoogleAuthProvider
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
@ -64,6 +65,14 @@ class FirebaseAuthDataSource @Inject constructor() {
.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()
suspend fun deleteAccount(): Unit =

View File

@ -19,6 +19,11 @@ class FirebaseAuthRepositoryImpl @Inject constructor(
override val currentUserEmail: String? get() = dataSource.currentUserEmail
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> =
withRateLimit(AuthRateLimiter.Flow.ANONYMOUS) {
runCatching { dataSource.signInAnonymously() }

View File

@ -8,6 +8,7 @@ interface AuthRepository {
val currentUserId: String?
val currentUserEmail: String?
val isSignedIn: Boolean
suspend fun signInWithGoogle(idToken: String): Result<String>
suspend fun signInAnonymously(): Result<String>
suspend fun signInWithEmail(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
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.Text
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.graphics.Brush
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.BackgroundColor
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.PrimaryColor
internal const val GOOGLE_WEB_CLIENT_ID =
"556235913214-l3risvbo7ouv80e22cojblufhjchgn1a.apps.googleusercontent.com"
internal val AuthBackgroundBrush: Brush
get() = Brush.linearGradient(
colors = listOf(BackgroundColor, CloserPalette.BackgroundWash, CloserPalette.PinkMist),
@ -26,6 +42,38 @@ internal val AuthPrimary = PrimaryColor
internal val AuthPrimaryDeep = CloserPalette.PurpleDeep
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
internal fun authTextFieldColors() = OutlinedTextFieldDefaults.colors(
focusedBorderColor = AuthPrimaryDeep,

View File

@ -3,6 +3,11 @@ package app.closer.ui.auth
import androidx.compose.foundation.background
import androidx.compose.foundation.BorderStroke
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@ -36,6 +41,9 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.Modifier
import androidx.compose.ui.focus.FocusDirection
@ -58,6 +66,8 @@ fun LoginScreen(
val state by viewModel.uiState.collectAsState()
val snackbar = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(state.success) {
if (state.success) onNavigate(AppRoute.HOME)
@ -168,6 +178,28 @@ fun LoginScreen(
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(
onClick = viewModel::signInAnonymously,
enabled = !state.isLoading,

View File

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