From 8f47593cf5b04bab2bc4bff04c229ba60c6625cc Mon Sep 17 00:00:00 2001 From: null Date: Sun, 21 Jun 2026 17:44:56 -0500 Subject: [PATCH] fix: user repository cleanup, settings VM, iOS app init + settings parity --- .../data/remote/FirestoreUserDataSource.kt | 43 +++++++--- .../data/repository/UserRepositoryImpl.kt | 3 + .../domain/repository/UserRepository.kt | 2 + .../app/closer/ui/settings/SettingsScreen.kt | 55 +++++++++--- .../closer/ui/settings/SettingsViewModel.kt | 51 ++++++++++- iphone/Closer/CloserApp.swift | 45 +++++++--- iphone/Closer/Settings/SettingsViews.swift | 86 +++++++++++++++---- 7 files changed, 228 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt index ad0c2f26..39c62df7 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt @@ -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 = 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( diff --git a/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt index b263b99c..7ebc16ab 100644 --- a/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt @@ -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 = dataSource.observeUser(uid) + override suspend fun createUser(user: User) = dataSource.createUser(user) override suspend fun updateDisplayName(uid: String, displayName: String) = diff --git a/app/src/main/java/app/closer/domain/repository/UserRepository.kt b/app/src/main/java/app/closer/domain/repository/UserRepository.kt index aefcf9a9..579ff9e4 100644 --- a/app/src/main/java/app/closer/domain/repository/UserRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/UserRepository.kt @@ -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 suspend fun createUser(user: User) suspend fun updateDisplayName(uid: String, displayName: String) suspend fun updatePhotoUrl(uid: String, photoUrl: String) diff --git a/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt b/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt index 60c734b1..f4354043 100644 --- a/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt @@ -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, diff --git a/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt b/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt index b1e6162d..944702c8 100644 --- a/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/app/closer/ui/settings/SettingsViewModel.kt @@ -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 = _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() diff --git a/iphone/Closer/CloserApp.swift b/iphone/Closer/CloserApp.swift index 7d141066..1b264cd2 100644 --- a/iphone/Closer/CloserApp.swift +++ b/iphone/Closer/CloserApp.swift @@ -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? + 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 \ No newline at end of file +import FirebaseMessaging diff --git a/iphone/Closer/Settings/SettingsViews.swift b/iphone/Closer/Settings/SettingsViews.swift index db80f073..bb5e6b58 100644 --- a/iphone/Closer/Settings/SettingsViews.swift +++ b/iphone/Closer/Settings/SettingsViews.swift @@ -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 {