feat: invite flow improvements, pairing success screen, iOS pairing updates
This commit is contained in:
parent
acebf24439
commit
7821bbbb40
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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<String?> = 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
|
||||
|
|
|
|||
|
|
@ -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<String?> = dataSource.watchInviteAccepted(code)
|
||||
|
||||
private fun isCodeConflict(e: Throwable): Boolean {
|
||||
val msg = e.message ?: return false
|
||||
return msg.contains("already-exists", ignoreCase = true) ||
|
||||
|
|
|
|||
|
|
@ -23,4 +23,5 @@ data class AcceptInviteResult(
|
|||
interface InviteRepository {
|
||||
suspend fun createInvite(): Result<CreateInviteResult>
|
||||
suspend fun acceptInvite(code: String): Result<AcceptInviteResult>
|
||||
fun watchAccepted(code: String): kotlinx.coroutines.flow.Flow<String?>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PairingSuccessUiState> = _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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue