fix: user repository cleanup, settings VM, iOS app init + settings parity

This commit is contained in:
null 2026-06-21 17:44:56 -05:00
parent 6b50e84f60
commit 8f47593cf5
7 changed files with 228 additions and 57 deletions

View File

@ -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(

View File

@ -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) =

View File

@ -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)

View File

@ -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,

View File

@ -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()

View File

@ -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,13 +88,38 @@ 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 }
@ -108,6 +128,7 @@ final class AppState: ObservableObject {
deinit {
authTask?.cancel()
partnerListener?.remove()
}
}
@ -127,4 +148,4 @@ enum Secrets {
// MARK: - Import for Messaging
import FirebaseMessaging
import FirebaseMessaging

View File

@ -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 {