feat: invite flow improvements, pairing success screen, iOS pairing updates

This commit is contained in:
null 2026-06-22 09:06:40 -05:00
parent acebf24439
commit 7821bbbb40
11 changed files with 459 additions and 17 deletions

View File

@ -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 = {

View File

@ -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 =

View File

@ -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

View File

@ -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) ||

View File

@ -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?>
}

View File

@ -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
}

View File

@ -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) }

View File

@ -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))
}
}

View File

@ -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)
)
}
}
}

View File

@ -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

View File

@ -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