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 app.closer.domain.model.User
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions 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 kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -14,29 +17,41 @@ import kotlin.coroutines.resumeWithException
class FirestoreUserDataSource @Inject constructor(private val db: FirebaseFirestore) { class FirestoreUserDataSource @Inject constructor(private val db: FirebaseFirestore) {
private fun userRef(uid: String) = db.collection(FirestoreCollections.USERS).document(uid) 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? = suspend fun getUser(uid: String): User? =
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
userRef(uid).get() userRef(uid).get()
.addOnSuccessListener { snap -> .addOnSuccessListener { snap ->
if (!snap.exists()) { cont.resume(null); return@addOnSuccessListener } if (!snap.exists()) { cont.resume(null); return@addOnSuccessListener }
cont.resume( cont.resume(snapshotToUser(snap.id, snap))
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
)
)
} }
.addOnFailureListener { cont.resumeWithException(it) } .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 = suspend fun createUser(user: User): Unit =
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
userRef(user.id).set( userRef(user.id).set(

View File

@ -6,6 +6,7 @@ import app.closer.domain.model.User
import app.closer.domain.repository.UserRepository import app.closer.domain.repository.UserRepository
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.flow.Flow
@Singleton @Singleton
class UserRepositoryImpl @Inject constructor( class UserRepositoryImpl @Inject constructor(
@ -14,6 +15,8 @@ class UserRepositoryImpl @Inject constructor(
override suspend fun getUser(uid: String): User? = dataSource.getUser(uid) 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 createUser(user: User) = dataSource.createUser(user)
override suspend fun updateDisplayName(uid: String, displayName: String) = 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.core.notifications.TokenRegistrar
import app.closer.domain.model.User import app.closer.domain.model.User
import kotlinx.coroutines.flow.Flow
interface UserRepository { interface UserRepository {
suspend fun getUser(uid: String): User? suspend fun getUser(uid: String): User?
fun observeUser(uid: String): Flow<User?>
suspend fun createUser(user: User) suspend fun createUser(user: User)
suspend fun updateDisplayName(uid: String, displayName: String) suspend fun updateDisplayName(uid: String, displayName: String)
suspend fun updatePhotoUrl(uid: String, photoUrl: String) suspend fun updatePhotoUrl(uid: String, photoUrl: String)

View File

@ -1,6 +1,7 @@
package app.closer.ui.settings package app.closer.ui.settings
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Notifications
import androidx.compose.material.icons.filled.Palette import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@ -56,8 +57,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.SettingsMuted
import app.closer.ui.settings.SettingsPrimaryDeep import app.closer.ui.settings.SettingsPrimaryDeep
import app.closer.ui.settings.SettingsSoft import app.closer.ui.settings.SettingsSoft
import coil.compose.AsyncImage
// ==================== // ====================
// Settings Subpages // Settings Subpages
@ -297,11 +302,10 @@ fun SettingsScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Icon( ProfileAvatar(
Icons.Filled.Person, imageUrl = state.photoUrl,
contentDescription = null, fallbackIcon = Icons.Filled.Person,
modifier = Modifier.size(40.dp), fallbackTint = SettingsPrimaryDeep
tint = SettingsPrimaryDeep
) )
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@ -351,11 +355,10 @@ fun SettingsScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Icon( ProfileAvatar(
if (state.isPaired) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder, imageUrl = state.partnerPhotoUrl,
contentDescription = null, fallbackIcon = if (state.isPaired) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
modifier = Modifier.size(40.dp), fallbackTint = if (state.isPaired) SettingsPrimaryDeep else SettingsMuted
tint = if (state.isPaired) SettingsPrimaryDeep else SettingsMuted
) )
Column( Column(
modifier = Modifier.weight(1f), 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 @Composable
private fun SettingsRow( private fun SettingsRow(
icon: ImageVector, 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.ThemeMode
import app.closer.domain.repository.UserRepository import app.closer.domain.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -31,8 +32,10 @@ import javax.inject.Inject
data class SettingsUiState( data class SettingsUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val displayName: String = "", val displayName: String = "",
val photoUrl: String = "",
val email: String = "", val email: String = "",
val partnerName: String? = null, val partnerName: String? = null,
val partnerPhotoUrl: String? = null,
val isPaired: Boolean = false, val isPaired: Boolean = false,
val isSigningOut: Boolean = false, val isSigningOut: Boolean = false,
val themeMode: ThemeMode = ThemeMode.DEVICE, val themeMode: ThemeMode = ThemeMode.DEVICE,
@ -56,6 +59,8 @@ class SettingsViewModel @Inject constructor(
private val _uiState = MutableStateFlow(SettingsUiState()) private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow() val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
private var currentUserJob: Job? = null
private var partnerUserJob: Job? = null
init { init {
loadSettings() loadSettings()
@ -87,9 +92,9 @@ class SettingsViewModel @Inject constructor(
.getOrNull() .getOrNull()
val couple = coupleRepository.getCoupleForUser(userId) val couple = coupleRepository.getCoupleForUser(userId)
val partnerId = couple?.userIds?.firstOrNull { it != userId } val partnerId = couple?.userIds?.firstOrNull { it != userId }
val partnerName = partnerId?.let { val partner = partnerId?.let {
runCatching { userRepository.getUser(it)?.displayName } runCatching { userRepository.getUser(it) }
.onFailure { e -> Log.w(TAG, "Could not load partner display name", e) } .onFailure { e -> Log.w(TAG, "Could not load partner profile", e) }
.getOrNull() .getOrNull()
} }
val outcomes = couple?.let { val outcomes = couple?.let {
@ -107,13 +112,49 @@ class SettingsViewModel @Inject constructor(
it.copy( it.copy(
isLoading = false, isLoading = false,
displayName = user?.displayName ?: "", displayName = user?.displayName ?: "",
photoUrl = user?.photoUrl ?: "",
email = email, email = email,
partnerName = partnerName, partnerName = partner?.displayName,
partnerPhotoUrl = partner?.photoUrl,
isPaired = couple != null, isPaired = couple != null,
outcomeBaselineDialogDue = outcomeBaselineDialogDue, outcomeBaselineDialogDue = outcomeBaselineDialogDue,
outcomeFollowUpDay = followUpDay 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() { fun signOut() {
_uiState.update { it.copy(isSigningOut = true) } _uiState.update { it.copy(isSigningOut = true) }
currentUserJob?.cancel()
partnerUserJob?.cancel()
viewModelScope.launch { viewModelScope.launch {
authRepository.signOut() authRepository.signOut()
recoveryPhraseStore.clear() recoveryPhraseStore.clear()

View File

@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import FirebaseCore import FirebaseCore
import FirebaseFirestore
import RevenueCat import RevenueCat
@main @main
@ -50,22 +51,14 @@ final class AppState: ObservableObject {
@Published var authState: AuthState = .loading @Published var authState: AuthState = .loading
@Published var currentUser: User? @Published var currentUser: User?
@Published var currentCouple: Couple? @Published var currentCouple: Couple?
@Published var currentPartner: User?
@Published var isPremium = false @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 authService = AuthService.shared
private let firestore = FirestoreService.shared private let firestore = FirestoreService.shared
private var authTask: Task<Void, Never>? private var authTask: Task<Void, Never>?
private var partnerListener: ListenerRegistration?
private var observedPartnerId: String?
init() { init() {
observeAuthState() observeAuthState()
@ -80,6 +73,8 @@ final class AppState: ObservableObject {
} else { } else {
self.currentUser = nil self.currentUser = nil
self.currentCouple = nil self.currentCouple = nil
self.currentPartner = nil
self.observePartner(nil)
} }
} }
} }
@ -93,13 +88,38 @@ final class AppState: ObservableObject {
if let coupleId = user?.coupleId { if let coupleId = user?.coupleId {
let couple: Couple? = try await firestore.getDocument(at: firestore.coupleDocument(coupleId)) let couple: Couple? = try await firestore.getDocument(at: firestore.coupleDocument(coupleId))
self.currentCouple = couple self.currentCouple = couple
let partnerId = couple?.userIds.first { $0 != userId }
observePartner(partnerId)
} else { } else {
self.currentCouple = nil self.currentCouple = nil
observePartner(nil)
} }
} catch { } catch {
print("Failed to load user data: \(error)") 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 { func refreshData() async {
guard case .authenticated(let userId, _) = authState else { return } guard case .authenticated(let userId, _) = authState else { return }
@ -108,6 +128,7 @@ final class AppState: ObservableObject {
deinit { deinit {
authTask?.cancel() authTask?.cancel()
partnerListener?.remove()
} }
} }
@ -127,4 +148,4 @@ enum Secrets {
// MARK: - Import for Messaging // MARK: - Import for Messaging
import FirebaseMessaging import FirebaseMessaging

View File

@ -22,7 +22,8 @@ struct SettingsView: View {
SettingsProfileHeader( SettingsProfileHeader(
initials: initials, initials: initials,
name: appState.currentUser?.displayName ?? "You", 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 { } else {
@ -32,7 +33,8 @@ struct SettingsView: View {
SettingsProfileHeader( SettingsProfileHeader(
initials: initials, initials: initials,
name: appState.currentUser?.displayName ?? "You", 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 { if let partner = appState.currentPartner {
SettingsLinkLabel( SettingsLinkLabel(
icon: "heart.fill", 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", subtitle: "Your shared Closer space is active",
tint: .closerPrimary tint: .closerPrimary,
imageUrl: partner.photoUrl
) )
} else { } else {
Button(action: { isPairingActive = true }) { Button(action: { isPairingActive = true }) {
@ -804,17 +807,17 @@ struct SettingsProfileHeader: View {
let initials: String let initials: String
let name: String let name: String
let detail: String let detail: String
var imageUrl: String? = nil
var body: some View { var body: some View {
VStack(spacing: CloserSpacing.sm) { VStack(spacing: CloserSpacing.sm) {
Circle() SettingsAvatarView(
.fill(Color.closerPrimary.opacity(0.18)) imageUrl: imageUrl,
.frame(width: 72, height: 72) fallbackIcon: nil,
.overlay( fallbackText: initials,
Text(initials) size: 72,
.font(CloserFont.title1) tint: .closerPrimary
.foregroundColor(.closerPrimary) )
)
Text(name) Text(name)
.font(CloserFont.title3) .font(CloserFont.title3)
@ -835,13 +838,17 @@ struct SettingsLinkLabel: View {
let title: String let title: String
let subtitle: String let subtitle: String
var tint: Color = .closerTextSecondary var tint: Color = .closerTextSecondary
var imageUrl: String? = nil
var body: some View { var body: some View {
HStack(spacing: CloserSpacing.md) { HStack(spacing: CloserSpacing.md) {
Image(systemName: icon) SettingsAvatarView(
.font(.headline) imageUrl: imageUrl,
.foregroundColor(tint) fallbackIcon: icon,
.frame(width: 28) fallbackText: nil,
size: 34,
tint: tint
)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(title) 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 // MARK: - Premium Settings CTA
struct PremiumSettingsCTA: View { struct PremiumSettingsCTA: View {