fix: user repository cleanup, settings VM, iOS app init + settings parity
This commit is contained in:
parent
6b50e84f60
commit
8f47593cf5
|
|
@ -4,6 +4,9 @@ import app.closer.core.notifications.TokenRegistrar.DeviceMetadata
|
|||
import app.closer.domain.model.User
|
||||
import com.google.firebase.firestore.FirebaseFirestore
|
||||
import com.google.firebase.firestore.SetOptions
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
|
@ -14,29 +17,41 @@ import kotlin.coroutines.resumeWithException
|
|||
class FirestoreUserDataSource @Inject constructor(private val db: FirebaseFirestore) {
|
||||
private fun userRef(uid: String) = db.collection(FirestoreCollections.USERS).document(uid)
|
||||
|
||||
private fun snapshotToUser(id: String, data: com.google.firebase.firestore.DocumentSnapshot): User =
|
||||
User(
|
||||
id = id,
|
||||
email = data.getString("email") ?: "",
|
||||
displayName = data.getString("displayName") ?: "",
|
||||
photoUrl = data.getString("photoUrl") ?: "",
|
||||
sex = data.getString("sex") ?: "",
|
||||
partnerId = data.getString("partnerId"),
|
||||
coupleId = data.getString("coupleId"),
|
||||
plan = data.getString("plan") ?: "free",
|
||||
createdAt = data.getLong("createdAt") ?: 0L,
|
||||
lastActiveAt = data.getLong("lastActiveAt") ?: 0L
|
||||
)
|
||||
|
||||
suspend fun getUser(uid: String): User? =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
userRef(uid).get()
|
||||
.addOnSuccessListener { snap ->
|
||||
if (!snap.exists()) { cont.resume(null); return@addOnSuccessListener }
|
||||
cont.resume(
|
||||
User(
|
||||
id = snap.id,
|
||||
email = snap.getString("email") ?: "",
|
||||
displayName = snap.getString("displayName") ?: "",
|
||||
photoUrl = snap.getString("photoUrl") ?: "",
|
||||
sex = snap.getString("sex") ?: "",
|
||||
partnerId = snap.getString("partnerId"),
|
||||
coupleId = snap.getString("coupleId"),
|
||||
plan = snap.getString("plan") ?: "free",
|
||||
createdAt = snap.getLong("createdAt") ?: 0L,
|
||||
lastActiveAt = snap.getLong("lastActiveAt") ?: 0L
|
||||
)
|
||||
)
|
||||
cont.resume(snapshotToUser(snap.id, snap))
|
||||
}
|
||||
.addOnFailureListener { cont.resumeWithException(it) }
|
||||
}
|
||||
|
||||
fun observeUser(uid: String): Flow<User?> = callbackFlow {
|
||||
val listener = userRef(uid).addSnapshotListener { snap, error ->
|
||||
if (error != null) {
|
||||
close(error)
|
||||
return@addSnapshotListener
|
||||
}
|
||||
trySend(snap?.takeIf { it.exists() }?.let { snapshotToUser(it.id, it) })
|
||||
}
|
||||
awaitClose { listener.remove() }
|
||||
}
|
||||
|
||||
suspend fun createUser(user: User): Unit =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
userRef(user.id).set(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import app.closer.domain.model.User
|
|||
import app.closer.domain.repository.UserRepository
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Singleton
|
||||
class UserRepositoryImpl @Inject constructor(
|
||||
|
|
@ -14,6 +15,8 @@ class UserRepositoryImpl @Inject constructor(
|
|||
|
||||
override suspend fun getUser(uid: String): User? = dataSource.getUser(uid)
|
||||
|
||||
override fun observeUser(uid: String): Flow<User?> = dataSource.observeUser(uid)
|
||||
|
||||
override suspend fun createUser(user: User) = dataSource.createUser(user)
|
||||
|
||||
override suspend fun updateDisplayName(uid: String, displayName: String) =
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ package app.closer.domain.repository
|
|||
|
||||
import app.closer.core.notifications.TokenRegistrar
|
||||
import app.closer.domain.model.User
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface UserRepository {
|
||||
suspend fun getUser(uid: String): User?
|
||||
fun observeUser(uid: String): Flow<User?>
|
||||
suspend fun createUser(user: User)
|
||||
suspend fun updateDisplayName(uid: String, displayName: String)
|
||||
suspend fun updatePhotoUrl(uid: String, photoUrl: String)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package app.closer.ui.settings
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -16,6 +17,7 @@ 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.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
|
|
@ -28,7 +30,6 @@ import androidx.compose.material.icons.filled.Lock
|
|||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
|
|
@ -56,8 +57,11 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -70,6 +74,7 @@ import app.closer.ui.settings.SettingsInk
|
|||
import app.closer.ui.settings.SettingsMuted
|
||||
import app.closer.ui.settings.SettingsPrimaryDeep
|
||||
import app.closer.ui.settings.SettingsSoft
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
// ====================
|
||||
// Settings Subpages
|
||||
|
|
@ -297,11 +302,10 @@ fun SettingsScreen(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Person,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
tint = SettingsPrimaryDeep
|
||||
ProfileAvatar(
|
||||
imageUrl = state.photoUrl,
|
||||
fallbackIcon = Icons.Filled.Person,
|
||||
fallbackTint = SettingsPrimaryDeep
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
|
|
@ -351,11 +355,10 @@ fun SettingsScreen(
|
|||
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) SettingsPrimaryDeep else SettingsMuted
|
||||
ProfileAvatar(
|
||||
imageUrl = state.partnerPhotoUrl,
|
||||
fallbackIcon = if (state.isPaired) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
|
||||
fallbackTint = if (state.isPaired) SettingsPrimaryDeep else SettingsMuted
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
|
|
@ -491,6 +494,36 @@ fun SettingsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileAvatar(
|
||||
imageUrl: String?,
|
||||
fallbackIcon: ImageVector,
|
||||
fallbackTint: androidx.compose.ui.graphics.Color,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val cleanUrl = imageUrl?.takeIf { it.isNotBlank() }
|
||||
if (cleanUrl != null) {
|
||||
AsyncImage(
|
||||
model = cleanUrl,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
placeholder = rememberVectorPainter(fallbackIcon),
|
||||
error = rememberVectorPainter(fallbackIcon),
|
||||
modifier = modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.border(1.5.dp, SettingsSoft, CircleShape)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
fallbackIcon,
|
||||
contentDescription = null,
|
||||
modifier = modifier.size(40.dp),
|
||||
tint = fallbackTint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsRow(
|
||||
icon: ImageVector,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import app.closer.domain.repository.SettingsRepository
|
|||
import app.closer.domain.repository.ThemeMode
|
||||
import app.closer.domain.repository.UserRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -31,8 +32,10 @@ import javax.inject.Inject
|
|||
data class SettingsUiState(
|
||||
val isLoading: Boolean = true,
|
||||
val displayName: String = "",
|
||||
val photoUrl: String = "",
|
||||
val email: String = "",
|
||||
val partnerName: String? = null,
|
||||
val partnerPhotoUrl: String? = null,
|
||||
val isPaired: Boolean = false,
|
||||
val isSigningOut: Boolean = false,
|
||||
val themeMode: ThemeMode = ThemeMode.DEVICE,
|
||||
|
|
@ -56,6 +59,8 @@ class SettingsViewModel @Inject constructor(
|
|||
|
||||
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||
private var currentUserJob: Job? = null
|
||||
private var partnerUserJob: Job? = null
|
||||
|
||||
init {
|
||||
loadSettings()
|
||||
|
|
@ -87,9 +92,9 @@ class SettingsViewModel @Inject constructor(
|
|||
.getOrNull()
|
||||
val couple = coupleRepository.getCoupleForUser(userId)
|
||||
val partnerId = couple?.userIds?.firstOrNull { it != userId }
|
||||
val partnerName = partnerId?.let {
|
||||
runCatching { userRepository.getUser(it)?.displayName }
|
||||
.onFailure { e -> Log.w(TAG, "Could not load partner display name", e) }
|
||||
val partner = partnerId?.let {
|
||||
runCatching { userRepository.getUser(it) }
|
||||
.onFailure { e -> Log.w(TAG, "Could not load partner profile", e) }
|
||||
.getOrNull()
|
||||
}
|
||||
val outcomes = couple?.let {
|
||||
|
|
@ -107,13 +112,49 @@ class SettingsViewModel @Inject constructor(
|
|||
it.copy(
|
||||
isLoading = false,
|
||||
displayName = user?.displayName ?: "",
|
||||
photoUrl = user?.photoUrl ?: "",
|
||||
email = email,
|
||||
partnerName = partnerName,
|
||||
partnerName = partner?.displayName,
|
||||
partnerPhotoUrl = partner?.photoUrl,
|
||||
isPaired = couple != null,
|
||||
outcomeBaselineDialogDue = outcomeBaselineDialogDue,
|
||||
outcomeFollowUpDay = followUpDay
|
||||
)
|
||||
}
|
||||
observeCurrentUser(userId)
|
||||
observePartnerUser(partnerId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeCurrentUser(userId: String) {
|
||||
currentUserJob?.cancel()
|
||||
currentUserJob = viewModelScope.launch {
|
||||
userRepository.observeUser(userId).collect { user ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
displayName = user?.displayName ?: it.displayName,
|
||||
photoUrl = user?.photoUrl ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observePartnerUser(partnerId: String?) {
|
||||
partnerUserJob?.cancel()
|
||||
if (partnerId == null) {
|
||||
_uiState.update { it.copy(partnerName = null, partnerPhotoUrl = null) }
|
||||
return
|
||||
}
|
||||
partnerUserJob = viewModelScope.launch {
|
||||
userRepository.observeUser(partnerId).collect { partner ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
partnerName = partner?.displayName,
|
||||
partnerPhotoUrl = partner?.photoUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -175,6 +216,8 @@ class SettingsViewModel @Inject constructor(
|
|||
|
||||
fun signOut() {
|
||||
_uiState.update { it.copy(isSigningOut = true) }
|
||||
currentUserJob?.cancel()
|
||||
partnerUserJob?.cancel()
|
||||
viewModelScope.launch {
|
||||
authRepository.signOut()
|
||||
recoveryPhraseStore.clear()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import SwiftUI
|
||||
import FirebaseCore
|
||||
import FirebaseFirestore
|
||||
import RevenueCat
|
||||
|
||||
@main
|
||||
|
|
@ -50,22 +51,14 @@ final class AppState: ObservableObject {
|
|||
@Published var authState: AuthState = .loading
|
||||
@Published var currentUser: User?
|
||||
@Published var currentCouple: Couple?
|
||||
@Published var currentPartner: User?
|
||||
@Published var isPremium = false
|
||||
|
||||
/// Derives the partner from the current couple + current user.
|
||||
/// TODO(iOS): Full implementation needs a Firestore lookup of the partner user document.
|
||||
/// For MVP, returns nil.
|
||||
var currentPartner: User? {
|
||||
guard let couple = currentCouple, let me = currentUser else { return nil }
|
||||
let partnerId = couple.userIds.first { $0 != me.id }
|
||||
_ = partnerId
|
||||
// TODO(iOS): Fetch partner User from Firestore using partnerId.
|
||||
return nil
|
||||
}
|
||||
|
||||
private let authService = AuthService.shared
|
||||
private let firestore = FirestoreService.shared
|
||||
private var authTask: Task<Void, Never>?
|
||||
private var partnerListener: ListenerRegistration?
|
||||
private var observedPartnerId: String?
|
||||
|
||||
init() {
|
||||
observeAuthState()
|
||||
|
|
@ -80,6 +73,8 @@ final class AppState: ObservableObject {
|
|||
} else {
|
||||
self.currentUser = nil
|
||||
self.currentCouple = nil
|
||||
self.currentPartner = nil
|
||||
self.observePartner(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -93,14 +88,39 @@ final class AppState: ObservableObject {
|
|||
if let coupleId = user?.coupleId {
|
||||
let couple: Couple? = try await firestore.getDocument(at: firestore.coupleDocument(coupleId))
|
||||
self.currentCouple = couple
|
||||
let partnerId = couple?.userIds.first { $0 != userId }
|
||||
observePartner(partnerId)
|
||||
} else {
|
||||
self.currentCouple = nil
|
||||
observePartner(nil)
|
||||
}
|
||||
} catch {
|
||||
print("Failed to load user data: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func observePartner(_ partnerId: String?) {
|
||||
guard observedPartnerId != partnerId else { return }
|
||||
|
||||
partnerListener?.remove()
|
||||
partnerListener = nil
|
||||
observedPartnerId = partnerId
|
||||
currentPartner = nil
|
||||
|
||||
guard let partnerId else { return }
|
||||
|
||||
partnerListener = firestore.userDocument(partnerId).addSnapshotListener { [weak self] snapshot, error in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
if let error {
|
||||
print("Failed to observe partner profile: \(error)")
|
||||
return
|
||||
}
|
||||
self.currentPartner = try? snapshot?.data(as: User.self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshData() async {
|
||||
guard case .authenticated(let userId, _) = authState else { return }
|
||||
await loadUserData(userId)
|
||||
|
|
@ -108,6 +128,7 @@ final class AppState: ObservableObject {
|
|||
|
||||
deinit {
|
||||
authTask?.cancel()
|
||||
partnerListener?.remove()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ struct SettingsView: View {
|
|||
SettingsProfileHeader(
|
||||
initials: initials,
|
||||
name: appState.currentUser?.displayName ?? "You",
|
||||
detail: "Create an account to save your Closer space"
|
||||
detail: "Create an account to save your Closer space",
|
||||
imageUrl: appState.currentUser?.photoUrl
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
|
@ -32,7 +33,8 @@ struct SettingsView: View {
|
|||
SettingsProfileHeader(
|
||||
initials: initials,
|
||||
name: appState.currentUser?.displayName ?? "You",
|
||||
detail: appState.currentUser?.email ?? "Edit your profile"
|
||||
detail: appState.currentUser?.email ?? "Edit your profile",
|
||||
imageUrl: appState.currentUser?.photoUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -42,9 +44,10 @@ struct SettingsView: View {
|
|||
if let partner = appState.currentPartner {
|
||||
SettingsLinkLabel(
|
||||
icon: "heart.fill",
|
||||
title: "Connected with \(partner.displayName ?? "Partner")",
|
||||
title: "Connected with \(partner.displayName.isEmpty ? "Partner" : partner.displayName)",
|
||||
subtitle: "Your shared Closer space is active",
|
||||
tint: .closerPrimary
|
||||
tint: .closerPrimary,
|
||||
imageUrl: partner.photoUrl
|
||||
)
|
||||
} else {
|
||||
Button(action: { isPairingActive = true }) {
|
||||
|
|
@ -804,17 +807,17 @@ struct SettingsProfileHeader: View {
|
|||
let initials: String
|
||||
let name: String
|
||||
let detail: String
|
||||
var imageUrl: String? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: CloserSpacing.sm) {
|
||||
Circle()
|
||||
.fill(Color.closerPrimary.opacity(0.18))
|
||||
.frame(width: 72, height: 72)
|
||||
.overlay(
|
||||
Text(initials)
|
||||
.font(CloserFont.title1)
|
||||
.foregroundColor(.closerPrimary)
|
||||
)
|
||||
SettingsAvatarView(
|
||||
imageUrl: imageUrl,
|
||||
fallbackIcon: nil,
|
||||
fallbackText: initials,
|
||||
size: 72,
|
||||
tint: .closerPrimary
|
||||
)
|
||||
|
||||
Text(name)
|
||||
.font(CloserFont.title3)
|
||||
|
|
@ -835,13 +838,17 @@ struct SettingsLinkLabel: View {
|
|||
let title: String
|
||||
let subtitle: String
|
||||
var tint: Color = .closerTextSecondary
|
||||
var imageUrl: String? = nil
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: CloserSpacing.md) {
|
||||
Image(systemName: icon)
|
||||
.font(.headline)
|
||||
.foregroundColor(tint)
|
||||
.frame(width: 28)
|
||||
SettingsAvatarView(
|
||||
imageUrl: imageUrl,
|
||||
fallbackIcon: icon,
|
||||
fallbackText: nil,
|
||||
size: 34,
|
||||
tint: tint
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
|
|
@ -856,6 +863,53 @@ struct SettingsLinkLabel: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct SettingsAvatarView: View {
|
||||
let imageUrl: String?
|
||||
let fallbackIcon: String?
|
||||
let fallbackText: String?
|
||||
let size: CGFloat
|
||||
let tint: Color
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(tint.opacity(0.16))
|
||||
|
||||
if let urlString = imageUrl, !urlString.isEmpty, let url = URL(string: urlString) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
default:
|
||||
fallback
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(Color.closerSurface, lineWidth: 1.5))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var fallback: some View {
|
||||
if let fallbackText {
|
||||
Text(fallbackText)
|
||||
.font(size >= 60 ? CloserFont.title1 : CloserFont.caption)
|
||||
.foregroundColor(tint)
|
||||
} else if let fallbackIcon {
|
||||
Image(systemName: fallbackIcon)
|
||||
.font(size >= 44 ? .title3 : .headline)
|
||||
.foregroundColor(tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Premium Settings CTA
|
||||
|
||||
struct PremiumSettingsCTA: View {
|
||||
|
|
|
|||
Loading…
Reference in New Issue