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.CreateInviteScreen
|
||||||
import app.closer.ui.pairing.InviteConfirmScreen
|
import app.closer.ui.pairing.InviteConfirmScreen
|
||||||
import app.closer.ui.pairing.PairPromptScreen
|
import app.closer.ui.pairing.PairPromptScreen
|
||||||
|
import app.closer.ui.pairing.PairingSuccessScreen
|
||||||
import app.closer.ui.pairing.RecoveryScreen
|
import app.closer.ui.pairing.RecoveryScreen
|
||||||
import app.closer.ui.pairing.EncryptionUpgradeScreen
|
import app.closer.ui.pairing.EncryptionUpgradeScreen
|
||||||
import app.closer.ui.dates.DateMatchScreen
|
import app.closer.ui.dates.DateMatchScreen
|
||||||
|
|
@ -291,6 +292,21 @@ fun AppNavigation(
|
||||||
onBack = navigateBackOrHome
|
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) {
|
composable(route = AppRoute.RECOVERY) {
|
||||||
RecoveryScreen(
|
RecoveryScreen(
|
||||||
onRecovered = {
|
onRecovered = {
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,9 @@ object AppRoute {
|
||||||
const val RECOVERY = "recovery"
|
const val RECOVERY = "recovery"
|
||||||
const val ENCRYPTION_UPGRADE = "encryption_upgrade"
|
const val ENCRYPTION_UPGRADE = "encryption_upgrade"
|
||||||
const val YOUR_PROGRESS = "your_progress"
|
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.
|
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
|
||||||
const val QUESTION_THREAD =
|
const val QUESTION_THREAD =
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import app.closer.crypto.RecoveryKeyManager
|
||||||
import app.closer.domain.repository.AcceptInviteResult
|
import app.closer.domain.repository.AcceptInviteResult
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
import com.google.firebase.functions.FirebaseFunctions
|
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 kotlinx.coroutines.tasks.await
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
@ -54,6 +57,16 @@ class FirestoreInviteDataSource @Inject constructor(
|
||||||
return CreateInviteResponse(returnedCode, expiresAt)
|
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(
|
data class CreateInviteResponse(
|
||||||
val code: String,
|
val code: String,
|
||||||
val expiresAt: com.google.firebase.Timestamp? = null
|
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.AcceptInviteResult
|
||||||
import app.closer.domain.repository.CreateInviteResult
|
import app.closer.domain.repository.CreateInviteResult
|
||||||
import app.closer.domain.repository.InviteRepository
|
import app.closer.domain.repository.InviteRepository
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
@ -62,6 +63,8 @@ class InviteRepositoryImpl @Inject constructor(
|
||||||
raw.copy(recoveryPhrase = phrase)
|
raw.copy(recoveryPhrase = phrase)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun watchAccepted(code: String): Flow<String?> = dataSource.watchInviteAccepted(code)
|
||||||
|
|
||||||
private fun isCodeConflict(e: Throwable): Boolean {
|
private fun isCodeConflict(e: Throwable): Boolean {
|
||||||
val msg = e.message ?: return false
|
val msg = e.message ?: return false
|
||||||
return msg.contains("already-exists", ignoreCase = true) ||
|
return msg.contains("already-exists", ignoreCase = true) ||
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,5 @@ data class AcceptInviteResult(
|
||||||
interface InviteRepository {
|
interface InviteRepository {
|
||||||
suspend fun createInvite(): Result<CreateInviteResult>
|
suspend fun createInvite(): Result<CreateInviteResult>
|
||||||
suspend fun acceptInvite(code: String): Result<AcceptInviteResult>
|
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)
|
rateLimiter.record(type.rateType)
|
||||||
|
|
||||||
val route = type.routeFor(payload)
|
val route = type.routeFor(payload, coupleId)
|
||||||
val notificationId = collapseId(type, coupleId)
|
val notificationId = collapseId(type, coupleId)
|
||||||
|
|
||||||
showNotification(notificationId, type, route)
|
showNotification(notificationId, type, route)
|
||||||
|
|
@ -225,7 +225,7 @@ enum class PartnerNotificationType(
|
||||||
/**
|
/**
|
||||||
* Builds the deep link route for this notification type.
|
* 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
|
PARTNER_ANSWERED -> AppRoute.DAILY_QUESTION
|
||||||
REVEAL_READY -> payload.questionId?.let { AppRoute.answerReveal(it) } ?: AppRoute.ANSWER_HISTORY
|
REVEAL_READY -> payload.questionId?.let { AppRoute.answerReveal(it) } ?: AppRoute.ANSWER_HISTORY
|
||||||
PARTNER_STARTED_GAME -> AppRoute.PLAY
|
PARTNER_STARTED_GAME -> AppRoute.PLAY
|
||||||
|
|
@ -236,7 +236,7 @@ enum class PartnerNotificationType(
|
||||||
DAILY_QUESTION_REMINDER -> AppRoute.DAILY_QUESTION
|
DAILY_QUESTION_REMINDER -> AppRoute.DAILY_QUESTION
|
||||||
CHAT_MESSAGE -> AppRoute.ANSWER_HISTORY
|
CHAT_MESSAGE -> AppRoute.ANSWER_HISTORY
|
||||||
OUTCOME_REMINDER -> AppRoute.SETTINGS
|
OUTCOME_REMINDER -> AppRoute.SETTINGS
|
||||||
PARTNER_JOINED -> AppRoute.HOME
|
PARTNER_JOINED -> if (coupleId.isNotBlank()) AppRoute.pairingSuccess(coupleId) else AppRoute.HOME
|
||||||
DATE_MATCH -> AppRoute.DATE_MATCHES
|
DATE_MATCH -> AppRoute.DATE_MATCHES
|
||||||
REENGAGEMENT -> AppRoute.DAILY_QUESTION
|
REENGAGEMENT -> AppRoute.DAILY_QUESTION
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
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
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -47,6 +49,7 @@ class CreateInviteViewModel @Inject constructor(
|
||||||
inviteRepository.createInvite()
|
inviteRepository.createInvite()
|
||||||
.onSuccess { result ->
|
.onSuccess { result ->
|
||||||
_uiState.update { it.copy(isLoading = false, inviteCode = result.code, recoveryPhrase = result.recoveryPhrase) }
|
_uiState.update { it.copy(isLoading = false, inviteCode = result.code, recoveryPhrase = result.recoveryPhrase) }
|
||||||
|
watchForAcceptance(result.code)
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
Log.e(TAG, "createInvite failed", 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 onNavigated() = _uiState.update { it.copy(navigateTo = null) }
|
||||||
fun dismissError() = _uiState.update { it.copy(error = null) }
|
fun dismissError() = _uiState.update { it.copy(error = null) }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,13 +60,13 @@ class InviteConfirmViewModel @Inject constructor(
|
||||||
if (phrase != null && result.wrappedKey.cipherB64.isNotBlank()) {
|
if (phrase != null && result.wrappedKey.cipherB64.isNotBlank()) {
|
||||||
encryptionManager.unwrapAndStore(result.coupleId, result.wrappedKey, phrase)
|
encryptionManager.unwrapAndStore(result.coupleId, result.wrappedKey, phrase)
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
navigateHome(result.inviterUserId)
|
navigateToPairingSuccess(result.coupleId)
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
_uiState.update { it.copy(isConfirming = false, error = recoveryErrorMessage(e)) }
|
_uiState.update { it.copy(isConfirming = false, error = recoveryErrorMessage(e)) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
navigateHome(result.inviterUserId)
|
navigateToPairingSuccess(result.coupleId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
|
|
@ -75,16 +75,9 @@ class InviteConfirmViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun navigateHome(inviterUserId: String) {
|
private fun navigateToPairingSuccess(coupleId: String) {
|
||||||
val inviterName = runCatching { userRepository.getUser(inviterUserId)?.displayName }
|
|
||||||
.onFailure { e -> Log.w(TAG, "Could not load inviter display name", e) }
|
|
||||||
.getOrNull()
|
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(isConfirming = false, navigateTo = AppRoute.pairingSuccess(coupleId))
|
||||||
isConfirming = false,
|
|
||||||
inviterName = inviterName ?: "your partner",
|
|
||||||
navigateTo = AppRoute.HOME
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
struct ContentView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
|
@State private var showPairingSuccess = false
|
||||||
|
@State private var hadCoupleId = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
rootView
|
rootView
|
||||||
}
|
}
|
||||||
.environmentObject(appState)
|
.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
|
@ViewBuilder
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,7 @@ struct AcceptInviteView: View {
|
||||||
@State private var code = ""
|
@State private var code = ""
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
@State private var showSuccess = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
|
@ -222,6 +223,10 @@ struct AcceptInviteView: View {
|
||||||
}
|
}
|
||||||
.background(Color.closerBackground)
|
.background(Color.closerBackground)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.fullScreenCover(isPresented: $showSuccess) {
|
||||||
|
PairingSuccessView()
|
||||||
|
.environmentObject(appState)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func acceptInvite() {
|
private func acceptInvite() {
|
||||||
|
|
@ -231,17 +236,125 @@ struct AcceptInviteView: View {
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let coupleId = try await FirestoreService.shared.acceptInviteCallable(code: code)
|
_ = try await FirestoreService.shared.acceptInviteCallable(code: code)
|
||||||
await appState.refreshData()
|
await appState.refreshData()
|
||||||
|
showSuccess = true
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = error.localizedDescription
|
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 {
|
struct InviteConfirmView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue