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:
parent
112de3398f
commit
78e145352b
|
|
@ -17,6 +17,7 @@ class FirebaseAuthDataSource @Inject constructor() {
|
||||||
private val auth = FirebaseAuth.getInstance()
|
private val auth = FirebaseAuth.getInstance()
|
||||||
|
|
||||||
val currentUserId: String? get() = auth.currentUser?.uid
|
val currentUserId: String? get() = auth.currentUser?.uid
|
||||||
|
val currentUserEmail: String? get() = auth.currentUser?.email
|
||||||
val isSignedIn: Boolean get() = auth.currentUser != null
|
val isSignedIn: Boolean get() = auth.currentUser != null
|
||||||
|
|
||||||
val authState: Flow<AuthState> = callbackFlow {
|
val authState: Flow<AuthState> = callbackFlow {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ class FirebaseAuthRepositoryImpl @Inject constructor(
|
||||||
|
|
||||||
override val authState: Flow<AuthState> = dataSource.authState
|
override val authState: Flow<AuthState> = dataSource.authState
|
||||||
override val currentUserId: String? get() = dataSource.currentUserId
|
override val currentUserId: String? get() = dataSource.currentUserId
|
||||||
|
override val currentUserEmail: String? get() = dataSource.currentUserEmail
|
||||||
override val isSignedIn: Boolean get() = dataSource.isSignedIn
|
override val isSignedIn: Boolean get() = dataSource.isSignedIn
|
||||||
|
|
||||||
override suspend fun signInAnonymously(): Result<String> =
|
override suspend fun signInAnonymously(): Result<String> =
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.Flow
|
||||||
interface AuthRepository {
|
interface AuthRepository {
|
||||||
val authState: Flow<AuthState>
|
val authState: Flow<AuthState>
|
||||||
val currentUserId: String?
|
val currentUserId: String?
|
||||||
|
val currentUserEmail: String?
|
||||||
val isSignedIn: Boolean
|
val isSignedIn: Boolean
|
||||||
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>
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,259 @@
|
||||||
package com.couplesconnect.app.ui.settings
|
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.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.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.core.navigation.AppRoute
|
||||||
import com.couplesconnect.app.ui.components.PlaceholderAction
|
|
||||||
import com.couplesconnect.app.ui.components.PlaceholderScreen
|
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
onNavigate: (String) -> Unit = {}
|
onNavigate: (String) -> Unit = {},
|
||||||
|
viewModel: SettingsViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
PlaceholderScreen(
|
val state by viewModel.uiState.collectAsState()
|
||||||
title = "Tend the edges",
|
|
||||||
section = "Settings",
|
LaunchedEffect(state.navigateTo) {
|
||||||
description = "The control center for account, privacy, notifications, subscription, and relationship preferences.",
|
state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() }
|
||||||
route = AppRoute.SETTINGS,
|
}
|
||||||
onNavigate = onNavigate,
|
|
||||||
accent = Color(0xFF6C8EA4),
|
Scaffold(
|
||||||
primaryAction = PlaceholderAction("Account", AppRoute.ACCOUNT),
|
topBar = {
|
||||||
secondaryAction = PlaceholderAction("Privacy", AppRoute.PRIVACY),
|
TopAppBar(title = { Text("Settings", style = MaterialTheme.typography.titleLarge) })
|
||||||
chips = listOf("Preferences", "Boundaries", "Careful controls"),
|
}
|
||||||
details = listOf(
|
) { padding ->
|
||||||
"Personal settings stay separate from couple content",
|
if (state.isLoading) {
|
||||||
"Privacy and notifications have focused places",
|
Column(
|
||||||
"Subscription management can connect to paywall later"
|
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
|
@Composable
|
||||||
fun SettingsScreenPreview() {
|
private fun SettingsRow(
|
||||||
SettingsScreen()
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue