From b29280ba8798b3b2f758d963e6d2443ffc347eb4 Mon Sep 17 00:00:00 2001 From: null Date: Mon, 22 Jun 2026 09:06:40 -0500 Subject: [PATCH] feat: invite flow improvements, pairing success screen, iOS pairing updates --- .../closer/core/navigation/AppNavigation.kt | 16 + .../app/closer/core/navigation/AppRoute.kt | 3 + .../data/remote/FirestoreInviteDataSource.kt | 13 + .../data/repository/InviteRepositoryImpl.kt | 3 + .../domain/repository/InviteRepository.kt | 1 + .../PartnerNotificationManager.kt | 6 +- .../ui/pairing/CreateInviteViewModel.kt | 10 + .../ui/pairing/InviteConfirmViewModel.kt | 15 +- .../closer/ui/pairing/PairingSuccessScreen.kt | 278 ++++++++++++++++++ iphone/Closer/Navigation/ContentView.swift | 12 + iphone/Closer/Pairing/PairingViews.swift | 119 +++++++- 11 files changed, 459 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index 70fdb13f..21f611ea 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -43,6 +43,7 @@ import app.closer.ui.pairing.AcceptInviteScreen import app.closer.ui.pairing.CreateInviteScreen import app.closer.ui.pairing.InviteConfirmScreen import app.closer.ui.pairing.PairPromptScreen +import app.closer.ui.pairing.PairingSuccessScreen import app.closer.ui.pairing.RecoveryScreen import app.closer.ui.pairing.EncryptionUpgradeScreen import app.closer.ui.dates.DateMatchScreen @@ -291,6 +292,21 @@ fun AppNavigation( onBack = navigateBackOrHome ) } + composable( + route = AppRoute.PAIRING_SUCCESS, + arguments = listOf(navArgument("coupleId") { type = NavType.StringType }), + deepLinks = listOf(navDeepLink { uriPattern = "closer://closer.app/pairing_success/{coupleId}" }) + ) { + PairingSuccessScreen( + coupleId = it.arguments?.getString("coupleId") ?: "", + onNavigate = { route -> + navController.navigate(route) { + popUpTo(0) { inclusive = true } + } + } + ) + } + composable(route = AppRoute.RECOVERY) { RecoveryScreen( onRecovered = { diff --git a/app/src/main/java/app/closer/core/navigation/AppRoute.kt b/app/src/main/java/app/closer/core/navigation/AppRoute.kt index 29d5b586..d63d1720 100644 --- a/app/src/main/java/app/closer/core/navigation/AppRoute.kt +++ b/app/src/main/java/app/closer/core/navigation/AppRoute.kt @@ -53,6 +53,9 @@ object AppRoute { const val RECOVERY = "recovery" const val ENCRYPTION_UPGRADE = "encryption_upgrade" const val YOUR_PROGRESS = "your_progress" + const val PAIRING_SUCCESS = "pairing_success/{coupleId}" + + fun pairingSuccess(coupleId: String) = "pairing_success/$coupleId" // Question thread: coupleId and questionId are required; prevId and nextId are optional. const val QUESTION_THREAD = diff --git a/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt index fee31e18..a5c48c8f 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt @@ -4,6 +4,9 @@ import app.closer.crypto.RecoveryKeyManager import app.closer.domain.repository.AcceptInviteResult import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.functions.FirebaseFunctions +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.tasks.await import javax.inject.Inject import javax.inject.Singleton @@ -54,6 +57,16 @@ class FirestoreInviteDataSource @Inject constructor( return CreateInviteResponse(returnedCode, expiresAt) } + fun watchInviteAccepted(code: String): Flow = callbackFlow { + val listener = db.collection("invites").document(code) + .addSnapshotListener { snapshot, _ -> + if (snapshot?.getString("status") == "accepted") { + trySend(snapshot.getString("coupleId")) + } + } + awaitClose { listener.remove() } + } + data class CreateInviteResponse( val code: String, val expiresAt: com.google.firebase.Timestamp? = null diff --git a/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt index d0ddaadf..1733c0ea 100644 --- a/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt @@ -8,6 +8,7 @@ import app.closer.data.remote.FirestoreInviteDataSource import app.closer.domain.repository.AcceptInviteResult import app.closer.domain.repository.CreateInviteResult import app.closer.domain.repository.InviteRepository +import kotlinx.coroutines.flow.Flow import javax.inject.Inject import javax.inject.Singleton import kotlin.random.Random @@ -62,6 +63,8 @@ class InviteRepositoryImpl @Inject constructor( raw.copy(recoveryPhrase = phrase) } + override fun watchAccepted(code: String): Flow = dataSource.watchInviteAccepted(code) + private fun isCodeConflict(e: Throwable): Boolean { val msg = e.message ?: return false return msg.contains("already-exists", ignoreCase = true) || diff --git a/app/src/main/java/app/closer/domain/repository/InviteRepository.kt b/app/src/main/java/app/closer/domain/repository/InviteRepository.kt index e800891a..a75f6365 100644 --- a/app/src/main/java/app/closer/domain/repository/InviteRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/InviteRepository.kt @@ -23,4 +23,5 @@ data class AcceptInviteResult( interface InviteRepository { suspend fun createInvite(): Result suspend fun acceptInvite(code: String): Result + fun watchAccepted(code: String): kotlinx.coroutines.flow.Flow } diff --git a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt index 7ecb8115..ba1b19e2 100644 --- a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt +++ b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt @@ -59,7 +59,7 @@ class PartnerNotificationManager @Inject constructor( rateLimiter.record(type.rateType) - val route = type.routeFor(payload) + val route = type.routeFor(payload, coupleId) val notificationId = collapseId(type, coupleId) showNotification(notificationId, type, route) @@ -225,7 +225,7 @@ enum class PartnerNotificationType( /** * Builds the deep link route for this notification type. */ - fun routeFor(payload: PartnerNotificationPayload): String = when (this) { + fun routeFor(payload: PartnerNotificationPayload, coupleId: String = ""): String = when (this) { PARTNER_ANSWERED -> AppRoute.DAILY_QUESTION REVEAL_READY -> payload.questionId?.let { AppRoute.answerReveal(it) } ?: AppRoute.ANSWER_HISTORY PARTNER_STARTED_GAME -> AppRoute.PLAY @@ -236,7 +236,7 @@ enum class PartnerNotificationType( DAILY_QUESTION_REMINDER -> AppRoute.DAILY_QUESTION CHAT_MESSAGE -> AppRoute.ANSWER_HISTORY OUTCOME_REMINDER -> AppRoute.SETTINGS - PARTNER_JOINED -> AppRoute.HOME + PARTNER_JOINED -> if (coupleId.isNotBlank()) AppRoute.pairingSuccess(coupleId) else AppRoute.HOME DATE_MATCH -> AppRoute.DATE_MATCHES REENGAGEMENT -> AppRoute.DAILY_QUESTION } diff --git a/app/src/main/java/app/closer/ui/pairing/CreateInviteViewModel.kt b/app/src/main/java/app/closer/ui/pairing/CreateInviteViewModel.kt index 6b9c9243..6aeeccb8 100644 --- a/app/src/main/java/app/closer/ui/pairing/CreateInviteViewModel.kt +++ b/app/src/main/java/app/closer/ui/pairing/CreateInviteViewModel.kt @@ -11,6 +11,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -47,6 +49,7 @@ class CreateInviteViewModel @Inject constructor( inviteRepository.createInvite() .onSuccess { result -> _uiState.update { it.copy(isLoading = false, inviteCode = result.code, recoveryPhrase = result.recoveryPhrase) } + watchForAcceptance(result.code) } .onFailure { e -> Log.e(TAG, "createInvite failed", e) @@ -55,6 +58,13 @@ class CreateInviteViewModel @Inject constructor( } } + private fun watchForAcceptance(code: String) { + viewModelScope.launch { + val coupleId = inviteRepository.watchAccepted(code).filterNotNull().first() + _uiState.update { it.copy(navigateTo = AppRoute.pairingSuccess(coupleId)) } + } + } + fun onNavigated() = _uiState.update { it.copy(navigateTo = null) } fun dismissError() = _uiState.update { it.copy(error = null) } diff --git a/app/src/main/java/app/closer/ui/pairing/InviteConfirmViewModel.kt b/app/src/main/java/app/closer/ui/pairing/InviteConfirmViewModel.kt index 302ac40b..eca7720b 100644 --- a/app/src/main/java/app/closer/ui/pairing/InviteConfirmViewModel.kt +++ b/app/src/main/java/app/closer/ui/pairing/InviteConfirmViewModel.kt @@ -60,13 +60,13 @@ class InviteConfirmViewModel @Inject constructor( if (phrase != null && result.wrappedKey.cipherB64.isNotBlank()) { encryptionManager.unwrapAndStore(result.coupleId, result.wrappedKey, phrase) .onSuccess { - navigateHome(result.inviterUserId) + navigateToPairingSuccess(result.coupleId) } .onFailure { e -> _uiState.update { it.copy(isConfirming = false, error = recoveryErrorMessage(e)) } } } else { - navigateHome(result.inviterUserId) + navigateToPairingSuccess(result.coupleId) } } .onFailure { e -> @@ -75,16 +75,9 @@ class InviteConfirmViewModel @Inject constructor( } } - private suspend fun navigateHome(inviterUserId: String) { - val inviterName = runCatching { userRepository.getUser(inviterUserId)?.displayName } - .onFailure { e -> Log.w(TAG, "Could not load inviter display name", e) } - .getOrNull() + private fun navigateToPairingSuccess(coupleId: String) { _uiState.update { - it.copy( - isConfirming = false, - inviterName = inviterName ?: "your partner", - navigateTo = AppRoute.HOME - ) + it.copy(isConfirming = false, navigateTo = AppRoute.pairingSuccess(coupleId)) } } diff --git a/app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt b/app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt new file mode 100644 index 00000000..9a167a7d --- /dev/null +++ b/app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt @@ -0,0 +1,278 @@ +package app.closer.ui.pairing + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +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.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.closer.core.navigation.AppRoute +import app.closer.domain.repository.AuthRepository +import app.closer.domain.repository.CoupleRepository +import app.closer.domain.repository.UserRepository +import coil.compose.AsyncImage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject +import app.closer.ui.settings.SettingsBackgroundBrush +import app.closer.ui.settings.SettingsInk +import app.closer.ui.settings.SettingsMuted +import app.closer.ui.settings.SettingsOnPrimary +import app.closer.ui.settings.SettingsPrimary +import app.closer.ui.settings.SettingsSoft + +// ── ViewModel ──────────────────────────────────────────────────────────────── + +data class PairingSuccessUiState( + val myName: String = "", + val myPhotoUrl: String = "", + val partnerName: String = "", + val partnerPhotoUrl: String = "", + val navigateTo: String? = null +) + +@HiltViewModel +class PairingSuccessViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val authRepository: AuthRepository, + private val userRepository: UserRepository, + private val coupleRepository: CoupleRepository +) : ViewModel() { + + private val coupleId: String = savedStateHandle["coupleId"] ?: "" + private val _uiState = MutableStateFlow(PairingSuccessUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + val myId = authRepository.currentUserId ?: return@launch + val me = userRepository.getUser(myId) + val couple = coupleRepository.getCoupleForUser(myId) + val partnerId = couple?.userIds?.firstOrNull { it != myId } + val partner = partnerId?.let { runCatching { userRepository.getUser(it) }.getOrNull() } + + _uiState.update { + it.copy( + myName = me?.displayName?.takeIf { n -> n.isNotBlank() } ?: "You", + myPhotoUrl = me?.photoUrl ?: "", + partnerName = partner?.displayName?.takeIf { n -> n.isNotBlank() } ?: "Your partner", + partnerPhotoUrl = partner?.photoUrl ?: "" + ) + } + } + } + + fun proceed() = _uiState.update { it.copy(navigateTo = AppRoute.HOME) } + fun onNavigated() = _uiState.update { it.copy(navigateTo = null) } +} + +// ── Screen ──────────────────────────────────────────────────────────────────── + +@Composable +fun PairingSuccessScreen( + coupleId: String, + onNavigate: (String) -> Unit = {}, + viewModel: PairingSuccessViewModel = hiltViewModel() +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(state.navigateTo) { + state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() } + } + + val pulse by rememberInfiniteTransition(label = "heart").animateFloat( + initialValue = 0.92f, + targetValue = 1.12f, + animationSpec = infiniteRepeatable( + animation = tween(750, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "heartScale" + ) + + Column( + modifier = Modifier + .fillMaxSize() + .background(SettingsBackgroundBrush) + .safeDrawingPadding() + .navigationBarsPadding() + .padding(horizontal = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Spacer(Modifier.weight(1f)) + + // Overlapping avatars + pulsing heart + Box( + modifier = Modifier + .width(144.dp) + .height(80.dp), + contentAlignment = Alignment.Center + ) { + PairAvatar( + url = state.myPhotoUrl, + modifier = Modifier + .size(80.dp) + .align(Alignment.CenterStart) + .zIndex(1f) + ) + PairAvatar( + url = state.partnerPhotoUrl, + modifier = Modifier + .size(80.dp) + .align(Alignment.CenterEnd) + .zIndex(1f) + ) + Box( + modifier = Modifier + .size(34.dp) + .zIndex(2f) + .align(Alignment.Center) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background) + .padding(3.dp) + .clip(CircleShape) + .background(SettingsSoft), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Filled.Favorite, + contentDescription = null, + tint = SettingsPrimary, + modifier = Modifier + .size(16.dp) + .scale(pulse) + ) + } + } + + Spacer(Modifier.height(28.dp)) + + Text( + text = if (state.myName.isNotBlank() && state.partnerName.isNotBlank()) + "${state.myName} & ${state.partnerName}" + else "You're connected", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold, + color = SettingsInk, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(10.dp)) + + Text( + "You're connected.", + style = MaterialTheme.typography.bodyLarge, + color = SettingsMuted, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(4.dp)) + Text( + "Ready to start your story together.", + style = MaterialTheme.typography.bodyMedium, + color = SettingsMuted, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.weight(1f)) + + Button( + onClick = viewModel::proceed, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = SettingsPrimary, + contentColor = SettingsOnPrimary + ) + ) { + Text("Start together", style = MaterialTheme.typography.labelLarge) + } + + Spacer(Modifier.height(24.dp)) + } +} + +// ── Shared avatar composable ────────────────────────────────────────────────── + +@Composable +private fun PairAvatar(url: String, modifier: Modifier = Modifier) { + val borderColor = SettingsSoft + val cleanUrl = url.takeIf { it.isNotBlank() } + if (cleanUrl != null) { + AsyncImage( + model = cleanUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + placeholder = rememberVectorPainter(Icons.Filled.Person), + error = rememberVectorPainter(Icons.Filled.Person), + modifier = modifier + .clip(CircleShape) + .border(2.5.dp, borderColor, CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + ) + } else { + Box( + modifier = modifier + .clip(CircleShape) + .border(2.5.dp, borderColor, CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Filled.Person, + contentDescription = null, + tint = SettingsMuted, + modifier = Modifier.size(36.dp) + ) + } + } +} diff --git a/iphone/Closer/Navigation/ContentView.swift b/iphone/Closer/Navigation/ContentView.swift index e76b4640..67e8c8f8 100644 --- a/iphone/Closer/Navigation/ContentView.swift +++ b/iphone/Closer/Navigation/ContentView.swift @@ -2,12 +2,24 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var appState: AppState + @State private var showPairingSuccess = false + @State private var hadCoupleId = false var body: some View { NavigationStack { rootView } .environmentObject(appState) + .fullScreenCover(isPresented: $showPairingSuccess) { + PairingSuccessView() + .environmentObject(appState) + } + .onChange(of: appState.currentUser?.coupleId) { _, newValue in + if !hadCoupleId && newValue != nil { + showPairingSuccess = true + } + hadCoupleId = newValue != nil + } } @ViewBuilder diff --git a/iphone/Closer/Pairing/PairingViews.swift b/iphone/Closer/Pairing/PairingViews.swift index 45d28b20..276fb9b3 100644 --- a/iphone/Closer/Pairing/PairingViews.swift +++ b/iphone/Closer/Pairing/PairingViews.swift @@ -167,6 +167,7 @@ struct AcceptInviteView: View { @State private var code = "" @State private var isLoading = false @State private var errorMessage: String? + @State private var showSuccess = false var body: some View { ScrollView { @@ -222,6 +223,10 @@ struct AcceptInviteView: View { } .background(Color.closerBackground) .navigationBarTitleDisplayMode(.inline) + .fullScreenCover(isPresented: $showSuccess) { + PairingSuccessView() + .environmentObject(appState) + } } private func acceptInvite() { @@ -231,17 +236,125 @@ struct AcceptInviteView: View { Task { do { - let coupleId = try await FirestoreService.shared.acceptInviteCallable(code: code) + _ = try await FirestoreService.shared.acceptInviteCallable(code: code) await appState.refreshData() + showSuccess = true } catch { errorMessage = error.localizedDescription + isLoading = false } - isLoading = false } } } -// MARK: - Invite Confirm +// MARK: - Pairing Success + +struct PairingSuccessView: View { + @EnvironmentObject var appState: AppState + @State private var heartScale: CGFloat = 1.0 + + var body: some View { + ZStack { + Color.closerBackground.ignoresSafeArea() + + VStack(spacing: 0) { + Spacer() + + avatarRow + .padding(.bottom, CloserSpacing.xl) + + Text(coupleTitle) + .font(CloserFont.title2) + .fontWeight(.semibold) + .foregroundColor(.closerText) + .multilineTextAlignment(.center) + .padding(.bottom, CloserSpacing.sm) + + Text("You're connected.") + .font(CloserFont.body) + .foregroundColor(.closerTextSecondary) + Text("Ready to start your story together.") + .font(CloserFont.callout) + .foregroundColor(.closerTextSecondary) + + Spacer() + + Button("Start together") { + Task { await appState.refreshData() } + } + .buttonStyle(PrimaryButtonStyle()) + .padding(.bottom, CloserSpacing.xl) + } + .padding(.horizontal, CloserSpacing.xl) + } + } + + private var coupleTitle: String { + let me = appState.currentUser?.displayName ?? "" + let partner = appState.currentPartner?.displayName ?? "" + if !me.isEmpty && !partner.isEmpty { return "\(me) & \(partner)" } + return "You're connected" + } + + private var avatarRow: some View { + ZStack { + HStack(spacing: -20) { + PairAvatarView(url: appState.currentUser?.photoUrl, size: 80) + PairAvatarView(url: appState.currentPartner?.photoUrl, size: 80) + } + // Pulsing heart badge centered between the two circles + ZStack { + Circle() + .fill(Color.closerBackground) + .frame(width: 34, height: 34) + Circle() + .fill(Color.closerPrimary.opacity(0.18)) + .frame(width: 28, height: 28) + Image(systemName: "heart.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.closerPrimary) + .scaleEffect(heartScale) + } + } + .onAppear { + withAnimation(.easeInOut(duration: 0.75).repeatForever(autoreverses: true)) { + heartScale = 1.25 + } + } + } +} + +private struct PairAvatarView: View { + let url: String? + let size: CGFloat + + var body: some View { + ZStack { + Circle() + .fill(Color.closerSurface) + if let urlString = url, !urlString.isEmpty, let parsed = URL(string: urlString) { + AsyncImage(url: parsed) { phase in + if case .success(let img) = phase { + img.resizable().scaledToFill() + } else { + Image(systemName: "person.fill") + .font(.system(size: size * 0.4)) + .foregroundColor(.closerTextSecondary) + } + } + } else { + Image(systemName: "person.fill") + .font(.system(size: size * 0.4)) + .foregroundColor(.closerTextSecondary) + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.closerSurface, lineWidth: 2.5)) + } +} + +// MARK: - Invite Confirm (legacy — retained for back-compat) struct InviteConfirmView: View { @EnvironmentObject var appState: AppState