feat(settings): SettingsViewModel, auth logout wiring, FirebaseAuth cleanup

- Added SettingsViewModel with logout/profile navigation
- Updated FirebaseAuthDataSource with signOut()
- Refined AuthRepository interface and FirebaseAuthRepositoryImpl
- Wired SettingsScreen to SettingsViewModel
This commit is contained in:
null 2026-06-16 00:27:29 -05:00
parent 112de3398f
commit 78e145352b
5 changed files with 324 additions and 23 deletions

View File

@ -17,6 +17,7 @@ class FirebaseAuthDataSource @Inject constructor() {
private val auth = FirebaseAuth.getInstance()
val currentUserId: String? get() = auth.currentUser?.uid
val currentUserEmail: String? get() = auth.currentUser?.email
val isSignedIn: Boolean get() = auth.currentUser != null
val authState: Flow<AuthState> = callbackFlow {

View File

@ -14,6 +14,7 @@ class FirebaseAuthRepositoryImpl @Inject constructor(
override val authState: Flow<AuthState> = dataSource.authState
override val currentUserId: String? get() = dataSource.currentUserId
override val currentUserEmail: String? get() = dataSource.currentUserEmail
override val isSignedIn: Boolean get() = dataSource.isSignedIn
override suspend fun signInAnonymously(): Result<String> =

View File

@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.Flow
interface AuthRepository {
val authState: Flow<AuthState>
val currentUserId: String?
val currentUserEmail: String?
val isSignedIn: Boolean
suspend fun signInAnonymously(): Result<String>
suspend fun signInWithEmail(email: String, password: String): Result<String>

View File

@ -1,36 +1,259 @@
package com.couplesconnect.app.ui.settings
import androidx.compose.foundation.clickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onNavigate: (String) -> Unit = {}
onNavigate: (String) -> Unit = {},
viewModel: SettingsViewModel = hiltViewModel()
) {
PlaceholderScreen(
title = "Tend the edges",
section = "Settings",
description = "The control center for account, privacy, notifications, subscription, and relationship preferences.",
route = AppRoute.SETTINGS,
onNavigate = onNavigate,
accent = Color(0xFF6C8EA4),
primaryAction = PlaceholderAction("Account", AppRoute.ACCOUNT),
secondaryAction = PlaceholderAction("Privacy", AppRoute.PRIVACY),
chips = listOf("Preferences", "Boundaries", "Careful controls"),
details = listOf(
"Personal settings stay separate from couple content",
"Privacy and notifications have focused places",
"Subscription management can connect to paywall later"
)
)
val state by viewModel.uiState.collectAsState()
LaunchedEffect(state.navigateTo) {
state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() }
}
Scaffold(
topBar = {
TopAppBar(title = { Text("Settings", style = MaterialTheme.typography.titleLarge) })
}
) { padding ->
if (state.isLoading) {
Column(
modifier = Modifier.fillMaxSize().padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(padding)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Profile card
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
Icons.Filled.Person,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = MaterialTheme.colorScheme.primary
)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = state.displayName.ifBlank { "No name set" },
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
if (state.email.isNotBlank()) {
Text(
text = state.email,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// Partner card
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
if (state.isPaired) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
contentDescription = null,
modifier = Modifier.size(40.dp),
tint = if (state.isPaired) Color(0xFFE07A5F) else MaterialTheme.colorScheme.onSurfaceVariant
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = if (state.isPaired) "Connected with" else "No partner yet",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (state.isPaired) {
Text(
text = state.partnerName ?: "Your partner",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
} else {
Text(
text = "Invite someone to connect",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
if (!state.isPaired) {
Icon(
Icons.AutoMirrored.Filled.ArrowForwardIos,
contentDescription = null,
modifier = Modifier
.size(16.dp)
.clickable { onNavigate(AppRoute.CREATE_INVITE) },
tint = MaterialTheme.colorScheme.primary
)
}
}
}
Spacer(Modifier.height(4.dp))
// General section
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Column {
SettingsRow(
icon = Icons.Filled.Notifications,
label = "Notifications",
onClick = { onNavigate(AppRoute.NOTIFICATIONS) }
)
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
SettingsRow(
icon = Icons.Filled.Star,
label = "Subscription",
onClick = { onNavigate(AppRoute.SUBSCRIPTION) }
)
Divider(modifier = Modifier.padding(horizontal = 16.dp), thickness = 0.5.dp)
SettingsRow(
icon = Icons.Filled.Lock,
label = "Privacy & Terms",
onClick = { onNavigate(AppRoute.PRIVACY) }
)
}
}
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = viewModel::signOut,
enabled = !state.isSigningOut,
modifier = Modifier.fillMaxWidth().height(52.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
if (state.isSigningOut) CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.error,
strokeWidth = 2.dp
)
else Text("Sign out", style = MaterialTheme.typography.labelLarge)
}
Spacer(Modifier.height(16.dp))
}
}
}
}
@Preview
@Composable
fun SettingsScreenPreview() {
SettingsScreen()
private fun SettingsRow(
icon: ImageVector,
label: String,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
Icon(
Icons.AutoMirrored.Filled.ArrowForwardIos,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@ -0,0 +1,75 @@
package com.couplesconnect.app.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.repository.AuthRepository
import com.couplesconnect.app.domain.repository.CoupleRepository
import com.couplesconnect.app.domain.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class SettingsUiState(
val isLoading: Boolean = true,
val displayName: String = "",
val email: String = "",
val partnerName: String? = null,
val isPaired: Boolean = false,
val isSigningOut: Boolean = false,
val navigateTo: String? = null
)
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val userRepository: UserRepository,
private val coupleRepository: CoupleRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
init {
loadSettings()
}
private fun loadSettings() {
viewModelScope.launch {
val userId = authRepository.currentUserId ?: run {
_uiState.update { it.copy(isLoading = false) }
return@launch
}
val email = authRepository.currentUserEmail ?: ""
val user = runCatching { userRepository.getUser(userId) }.getOrNull()
val couple = coupleRepository.getCoupleForUser(userId)
val partnerId = couple?.userIds?.firstOrNull { it != userId }
val partnerName = partnerId?.let {
runCatching { userRepository.getUser(it)?.displayName }.getOrNull()
}
_uiState.update {
it.copy(
isLoading = false,
displayName = user?.displayName ?: "",
email = email,
partnerName = partnerName,
isPaired = couple != null
)
}
}
}
fun signOut() {
_uiState.update { it.copy(isSigningOut = true) }
viewModelScope.launch {
authRepository.signOut()
_uiState.update { it.copy(isSigningOut = false, navigateTo = AppRoute.ONBOARDING) }
}
}
fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
}