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 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(
|
||||||
|
|
|
||||||
|
|
@ -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) =
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue