fix: extract Firebase auth calls into AuthRepository — LoginViewModel no longer depends on Firebase directly
This commit is contained in:
parent
370a56069f
commit
8217a3385d
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue