feat(pairing): Firebase Firestore invite/couple data sources with ViewModels

- Added FirestoreCoupleDataSource, FirestoreInviteDataSource
- CoupleRepository + InviteRepository interfaces and Firestore impls
- AcceptInviteViewModel, CreateInviteViewModel, InviteConfirmViewModel
- Updated AcceptInviteScreen, CreateInviteScreen, InviteConfirmScreen with ViewModel-backed state
- Updated RepositoryModule and OnboardingViewModel for new dependencies
This commit is contained in:
null 2026-06-16 00:22:20 -05:00
parent 8bcb3308c1
commit 112de3398f
14 changed files with 879 additions and 80 deletions

View File

@ -0,0 +1,80 @@
package com.couplesconnect.app.data.remote
import com.couplesconnect.app.domain.model.Couple
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Singleton
class FirestoreCoupleDataSource @Inject constructor() {
private val db = FirebaseFirestore.getInstance()
private fun coupleRef(coupleId: String) = db.collection("couples").document(coupleId)
private fun userRef(uid: String) = db.collection("users").document(uid)
suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): String {
val coupleId = UUID.randomUUID().toString()
val now = System.currentTimeMillis()
createCoupleDoc(coupleId, inviterUserId, acceptorUserId, inviteCode, now)
updateUserCoupleId(inviterUserId, coupleId)
updateUserCoupleId(acceptorUserId, coupleId)
return coupleId
}
private suspend fun createCoupleDoc(
coupleId: String,
inviterUserId: String,
acceptorUserId: String,
inviteCode: String,
now: Long
): Unit = suspendCancellableCoroutine { cont ->
coupleRef(coupleId).set(
mapOf(
"id" to coupleId,
"userIds" to listOf(inviterUserId, acceptorUserId),
"inviteCode" to inviteCode,
"createdAt" to now,
"streakCount" to 0
)
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun updateUserCoupleId(uid: String, coupleId: String): Unit =
suspendCancellableCoroutine { cont ->
userRef(uid).set(
mapOf("coupleId" to coupleId),
SetOptions.merge()
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun getCoupleById(coupleId: String): Couple? =
suspendCancellableCoroutine { cont ->
coupleRef(coupleId).get()
.addOnSuccessListener { snap ->
if (!snap.exists()) { cont.resume(null); return@addOnSuccessListener }
@Suppress("UNCHECKED_CAST")
cont.resume(
Couple(
id = snap.id,
userIds = (snap.get("userIds") as? List<String>) ?: emptyList(),
inviteCode = snap.getString("inviteCode") ?: "",
createdAt = snap.getLong("createdAt") ?: 0L,
currentQuestionId = snap.getString("currentQuestionId"),
streakCount = (snap.getLong("streakCount") ?: 0L).toInt(),
lastAnsweredAt = snap.getLong("lastAnsweredAt"),
activePackId = snap.getString("activePackId")
)
)
}
.addOnFailureListener { cont.resumeWithException(it) }
}
}

View File

@ -0,0 +1,80 @@
package com.couplesconnect.app.data.remote
import com.couplesconnect.app.domain.model.Invite
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.suspendCancellableCoroutine
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.random.Random
@Singleton
class FirestoreInviteDataSource @Inject constructor() {
private val db = FirebaseFirestore.getInstance()
private fun inviteRef(code: String) = db.collection("invites").document(code)
fun generateCode(): String = (1..6)
.map { CODE_CHARS[Random.nextInt(CODE_CHARS.length)] }
.joinToString("")
suspend fun createInvite(code: String, inviterUserId: String): Unit =
suspendCancellableCoroutine { cont ->
val now = System.currentTimeMillis()
inviteRef(code).set(
mapOf(
"code" to code,
"inviterUserId" to inviterUserId,
"status" to "pending",
"createdAt" to now,
"expiresAt" to now + 24 * 60 * 60 * 1000L
)
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun getInviteByCode(code: String): Invite? =
suspendCancellableCoroutine { cont ->
inviteRef(code).get()
.addOnSuccessListener { snap ->
if (!snap.exists()) { cont.resume(null); return@addOnSuccessListener }
cont.resume(
Invite(
id = snap.id,
code = snap.getString("code") ?: snap.id,
inviterUserId = snap.getString("inviterUserId") ?: "",
inviteeEmail = snap.getString("inviteeEmail"),
coupleId = snap.getString("coupleId"),
status = snap.getString("status") ?: "pending",
createdAt = snap.getLong("createdAt") ?: 0L,
expiresAt = snap.getLong("expiresAt") ?: 0L,
acceptedAt = snap.getLong("acceptedAt"),
acceptedByUserId = snap.getString("acceptedByUserId")
)
)
}
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Unit =
suspendCancellableCoroutine { cont ->
inviteRef(code).set(
mapOf(
"status" to "accepted",
"acceptedByUserId" to acceptorUserId,
"acceptedAt" to System.currentTimeMillis(),
"coupleId" to coupleId
),
SetOptions.merge()
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
companion object {
private const val CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
}
}

View File

@ -0,0 +1,24 @@
package com.couplesconnect.app.data.repository
import com.couplesconnect.app.data.remote.FirestoreCoupleDataSource
import com.couplesconnect.app.data.remote.FirestoreUserDataSource
import com.couplesconnect.app.domain.model.Couple
import com.couplesconnect.app.domain.repository.CoupleRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CoupleRepositoryImpl @Inject constructor(
private val coupleDataSource: FirestoreCoupleDataSource,
private val userDataSource: FirestoreUserDataSource
) : CoupleRepository {
override suspend fun getCoupleForUser(userId: String): Couple? {
val coupleId = runCatching { userDataSource.getUser(userId)?.coupleId }.getOrNull() ?: return null
return runCatching { coupleDataSource.getCoupleById(coupleId) }.getOrNull()
}
override suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): Result<String> = runCatching {
coupleDataSource.createCouple(inviterUserId, acceptorUserId, inviteCode)
}
}

View File

@ -0,0 +1,27 @@
package com.couplesconnect.app.data.repository
import com.couplesconnect.app.data.remote.FirestoreInviteDataSource
import com.couplesconnect.app.domain.model.Invite
import com.couplesconnect.app.domain.repository.InviteRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class InviteRepositoryImpl @Inject constructor(
private val dataSource: FirestoreInviteDataSource
) : InviteRepository {
override suspend fun createInvite(inviterUserId: String): Result<String> = runCatching {
val code = dataSource.generateCode()
dataSource.createInvite(code, inviterUserId)
code
}
override suspend fun getInviteByCode(code: String): Result<Invite?> = runCatching {
dataSource.getInviteByCode(code)
}
override suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Result<Unit> = runCatching {
dataSource.markAccepted(code, acceptorUserId, coupleId)
}
}

View File

@ -1,11 +1,15 @@
package com.couplesconnect.app.di
import com.couplesconnect.app.data.repository.CoupleRepositoryImpl
import com.couplesconnect.app.data.repository.FirebaseAuthRepositoryImpl
import com.couplesconnect.app.data.repository.InviteRepositoryImpl
import com.couplesconnect.app.data.repository.SharedPreferencesLocalAnswerRepository
import com.couplesconnect.app.data.repository.RoomQuestionRepository
import com.couplesconnect.app.data.repository.QuestionThreadRepositoryImpl
import com.couplesconnect.app.data.repository.UserRepositoryImpl
import com.couplesconnect.app.domain.repository.AuthRepository
import com.couplesconnect.app.domain.repository.CoupleRepository
import com.couplesconnect.app.domain.repository.InviteRepository
import com.couplesconnect.app.domain.repository.LocalAnswerRepository
import com.couplesconnect.app.domain.repository.QuestionRepository
import com.couplesconnect.app.domain.repository.QuestionThreadRepository
@ -26,6 +30,12 @@ abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
@Binds @Singleton
abstract fun bindInviteRepository(impl: InviteRepositoryImpl): InviteRepository
@Binds @Singleton
abstract fun bindCoupleRepository(impl: CoupleRepositoryImpl): CoupleRepository
@Binds @Singleton
abstract fun bindQuestionThreadRepository(impl: QuestionThreadRepositoryImpl): QuestionThreadRepository

View File

@ -0,0 +1,8 @@
package com.couplesconnect.app.domain.repository
import com.couplesconnect.app.domain.model.Couple
interface CoupleRepository {
suspend fun getCoupleForUser(userId: String): Couple?
suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): Result<String>
}

View File

@ -0,0 +1,9 @@
package com.couplesconnect.app.domain.repository
import com.couplesconnect.app.domain.model.Invite
interface InviteRepository {
suspend fun createInvite(inviterUserId: String): Result<String>
suspend fun getInviteByCode(code: String): Result<Invite?>
suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Result<Unit>
}

View File

@ -34,8 +34,12 @@ class OnboardingViewModel @Inject constructor(
is AuthState.Loading -> _uiState.update { it.copy(isCheckingAuth = true) }
is AuthState.Unauthenticated -> _uiState.update { it.copy(isCheckingAuth = false, navigateTo = null) }
is AuthState.Authenticated -> {
val hasProfile = runCatching { userRepository.hasProfile(authState.userId) }.getOrDefault(false)
val destination = if (hasProfile) "home" else "create_profile"
val user = runCatching { userRepository.getUser(authState.userId) }.getOrNull()
val destination = when {
user == null || user.displayName.isBlank() -> "create_profile"
user.coupleId != null -> "home"
else -> "create_invite"
}
_uiState.update { it.copy(isCheckingAuth = false, navigateTo = destination) }
}
}

View File

@ -1,36 +1,153 @@
package com.couplesconnect.app.ui.pairing
import androidx.compose.foundation.layout.Arrangement
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AcceptInviteScreen(
onNavigate: (String) -> Unit = {}
onNavigate: (String) -> Unit = {},
viewModel: AcceptInviteViewModel = hiltViewModel()
) {
PlaceholderScreen(
title = "Join with care",
section = "Pairing",
description = "A future code-entry moment for accepting an invitation and confirming the couple context.",
route = AppRoute.ACCEPT_INVITE,
onNavigate = onNavigate,
accent = Color(0xFFE07A5F),
primaryAction = PlaceholderAction("Confirm sample", AppRoute.inviteConfirm("ABC123")),
secondaryAction = PlaceholderAction("Create invite", AppRoute.CREATE_INVITE),
chips = listOf("Code entry", "Partner consent", "Sample code"),
details = listOf(
"Invite lookup can stay careful and transparent",
"The confirmation screen receives the code",
"Pairing can wait for an explicit confirmation"
)
)
val state by viewModel.uiState.collectAsState()
val snackbar = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current
LaunchedEffect(state.navigateTo) {
state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() }
}
@Preview
@Composable
fun AcceptInviteScreenPreview() {
AcceptInviteScreen()
Scaffold(
snackbarHost = { SnackbarHost(snackbar) },
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = { onNavigate(AppRoute.CREATE_INVITE) }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.imePadding()
.verticalScroll(rememberScrollState())
.padding(padding)
.padding(horizontal = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Spacer(Modifier.height(24.dp))
Text(
"Enter the code",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(8.dp))
Text(
"Ask your partner to share their 6-character invite code.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
textAlign = TextAlign.Center
)
Spacer(Modifier.height(36.dp))
OutlinedTextField(
value = state.code,
onValueChange = viewModel::updateCode,
label = { Text("Invite code") },
placeholder = { Text("ABC123") },
singleLine = true,
isError = state.error != null,
supportingText = state.error?.let { error -> { Text(error, color = MaterialTheme.colorScheme.error) } },
modifier = Modifier.fillMaxWidth(),
textStyle = MaterialTheme.typography.headlineSmall.copy(
textAlign = TextAlign.Center,
letterSpacing = androidx.compose.ui.unit.TextUnit.Unspecified
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
capitalization = KeyboardCapitalization.Characters,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus(); viewModel.lookupCode() })
)
Spacer(Modifier.height(24.dp))
Button(
onClick = { focusManager.clearFocus(); viewModel.lookupCode() },
enabled = !state.isLoading && state.code.length == 6,
modifier = Modifier.fillMaxWidth().height(52.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
) {
if (state.isLoading) CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
else Text("Continue", style = MaterialTheme.typography.labelLarge)
}
Spacer(Modifier.height(16.dp))
TextButton(onClick = { onNavigate(AppRoute.CREATE_INVITE) }) {
Text(
"Need to create an invite instead?",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}

View File

@ -0,0 +1,68 @@
package com.couplesconnect.app.ui.pairing
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.repository.InviteRepository
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
data class AcceptInviteUiState(
val code: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val navigateTo: String? = null
)
@HiltViewModel
class AcceptInviteViewModel @Inject constructor(
private val inviteRepository: InviteRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(AcceptInviteUiState())
val uiState: StateFlow<AcceptInviteUiState> = _uiState.asStateFlow()
fun updateCode(raw: String) {
val filtered = raw.uppercase().filter { it in CODE_CHARS }.take(6)
_uiState.update { it.copy(code = filtered, error = null) }
}
fun lookupCode() {
val code = _uiState.value.code
if (code.length < 6) {
_uiState.update { it.copy(error = "Please enter all 6 characters.") }
return
}
_uiState.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch {
inviteRepository.getInviteByCode(code)
.onSuccess { invite ->
when {
invite == null ->
_uiState.update { it.copy(isLoading = false, error = "Code not found. Double-check with your partner.") }
invite.status != "pending" ->
_uiState.update { it.copy(isLoading = false, error = "This code has already been used.") }
invite.expiresAt < System.currentTimeMillis() ->
_uiState.update { it.copy(isLoading = false, error = "This code has expired. Ask your partner to create a new one.") }
else ->
_uiState.update { it.copy(isLoading = false, navigateTo = AppRoute.inviteConfirm(code)) }
}
}
.onFailure {
_uiState.update { it.copy(isLoading = false, error = "Couldn't find that code. Please try again.") }
}
}
}
fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
fun dismissError() = _uiState.update { it.copy(error = null) }
companion object {
private const val CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
}
}

View File

@ -1,36 +1,159 @@
package com.couplesconnect.app.ui.pairing
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
import kotlinx.coroutines.launch
@Composable
fun CreateInviteScreen(
onNavigate: (String) -> Unit = {}
onNavigate: (String) -> Unit = {},
viewModel: CreateInviteViewModel = hiltViewModel()
) {
PlaceholderScreen(
title = "Invite your person",
section = "Pairing",
description = "The future start of partner pairing, with shareable invite choices and clear privacy framing.",
route = AppRoute.CREATE_INVITE,
onNavigate = onNavigate,
accent = Color(0xFF81B29A),
primaryAction = PlaceholderAction("Email invite", AppRoute.EMAIL_INVITE),
secondaryAction = PlaceholderAction("Accept code", AppRoute.ACCEPT_INVITE),
chips = listOf("Pairing", "Share", "Consent-first"),
details = listOf(
"Invite creation can sit here after auth is available",
"Manual code and email paths both have room",
"Confirmation can feel explicit and reassuring"
val state by viewModel.uiState.collectAsState()
val snackbar = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val clipboard = LocalClipboardManager.current
LaunchedEffect(state.navigateTo) {
state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() }
}
LaunchedEffect(state.error) {
state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() }
}
Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.verticalScroll(rememberScrollState())
.padding(padding)
.padding(horizontal = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(40.dp))
} else if (state.inviteCode != null) {
Spacer(Modifier.height(48.dp))
Text(
"Invite your person",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(8.dp))
Text(
"Share this code with your partner. They'll enter it to connect.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
textAlign = TextAlign.Center
)
Spacer(Modifier.height(40.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = state.inviteCode!!.chunked(3).joinToString(" "),
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
textAlign = TextAlign.Center,
letterSpacing = androidx.compose.ui.unit.TextUnit.Unspecified
)
}
}
Spacer(Modifier.height(20.dp))
Button(
onClick = {
clipboard.setText(AnnotatedString(state.inviteCode!!))
scope.launch { snackbar.showSnackbar("Code copied!") }
},
modifier = Modifier.fillMaxWidth().height(52.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("Copy code", style = MaterialTheme.typography.labelLarge)
}
}
Spacer(Modifier.height(12.dp))
Text(
"Code expires in 24 hours",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.45f),
textAlign = TextAlign.Center
)
Spacer(Modifier.height(32.dp))
TextButton(onClick = { onNavigate(AppRoute.ACCEPT_INVITE) }) {
Text(
"Partner already has a code? Accept instead",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
@Preview
@Composable
fun CreateInviteScreenPreview() {
CreateInviteScreen()
Spacer(Modifier.height(32.dp))
}
}
}
}

View File

@ -0,0 +1,57 @@
package com.couplesconnect.app.ui.pairing
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.repository.AuthRepository
import com.couplesconnect.app.domain.repository.CoupleRepository
import com.couplesconnect.app.domain.repository.InviteRepository
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
data class CreateInviteUiState(
val isLoading: Boolean = true,
val inviteCode: String? = null,
val error: String? = null,
val navigateTo: String? = null
)
@HiltViewModel
class CreateInviteViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val inviteRepository: InviteRepository,
private val coupleRepository: CoupleRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(CreateInviteUiState())
val uiState: StateFlow<CreateInviteUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
val userId = authRepository.currentUserId ?: run {
_uiState.update { it.copy(isLoading = false, error = "Not signed in.") }
return@launch
}
val couple = coupleRepository.getCoupleForUser(userId)
if (couple != null) {
_uiState.update { it.copy(isLoading = false, navigateTo = AppRoute.HOME) }
return@launch
}
inviteRepository.createInvite(userId)
.onSuccess { code ->
_uiState.update { it.copy(isLoading = false, inviteCode = code) }
}
.onFailure { e ->
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't create invite. Please try again.") }
}
}
}
fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
fun dismissError() = _uiState.update { it.copy(error = null) }
}

View File

@ -1,37 +1,146 @@
package com.couplesconnect.app.ui.pairing
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.ui.components.PlaceholderAction
import com.couplesconnect.app.ui.components.PlaceholderScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun InviteConfirmScreen(
inviteCode: String,
onNavigate: (String) -> Unit = {}
onNavigate: (String) -> Unit = {},
viewModel: InviteConfirmViewModel = hiltViewModel()
) {
PlaceholderScreen(
title = "Confirm the match",
section = "Pairing",
description = "The future confirmation step before two accounts become one couple space.",
route = AppRoute.inviteConfirm(inviteCode),
onNavigate = onNavigate,
accent = Color(0xFF81B29A),
primaryAction = PlaceholderAction("Home", AppRoute.HOME),
secondaryAction = PlaceholderAction("Settings", AppRoute.SETTINGS),
chips = listOf("Invite $inviteCode", "Confirm", "Couple space"),
details = listOf(
"The invite code stays visible",
"Partner identity checks can be layered in later",
"Completing pairing can return home"
val state by viewModel.uiState.collectAsState()
val snackbar = remember { SnackbarHostState() }
LaunchedEffect(state.navigateTo) {
state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() }
}
LaunchedEffect(state.error) {
state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() }
}
Scaffold(
snackbarHost = { SnackbarHost(snackbar) },
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = { onNavigate(AppRoute.ACCEPT_INVITE) }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.verticalScroll(rememberScrollState())
.padding(padding)
.padding(horizontal = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(40.dp))
} else {
Spacer(Modifier.height(16.dp))
Text(
"",
style = MaterialTheme.typography.displayMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(20.dp))
Text(
"Pair with ${state.inviterName}?",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(12.dp))
Text(
"Once you confirm, you'll be connected and can start exploring questions together.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
Text(
"Code: $inviteCode",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.4f)
)
Spacer(Modifier.height(32.dp))
Button(
onClick = viewModel::confirmPairing,
enabled = !state.isConfirming,
modifier = Modifier.fillMaxWidth().height(56.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary)
) {
if (state.isConfirming) CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
else Text("Pair up", style = MaterialTheme.typography.labelLarge)
}
Spacer(Modifier.height(16.dp))
TextButton(onClick = { onNavigate(AppRoute.ACCEPT_INVITE) }) {
Text(
"That's not right — go back",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
@Preview
@Composable
fun InviteConfirmScreenPreview() {
InviteConfirmScreen(inviteCode = "ABC123")
Spacer(Modifier.height(32.dp))
}
}
}
}

View File

@ -0,0 +1,83 @@
package com.couplesconnect.app.ui.pairing
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.couplesconnect.app.core.navigation.AppRoute
import com.couplesconnect.app.domain.model.Invite
import com.couplesconnect.app.domain.repository.AuthRepository
import com.couplesconnect.app.domain.repository.CoupleRepository
import com.couplesconnect.app.domain.repository.InviteRepository
import com.couplesconnect.app.domain.repository.UserRepository
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
data class InviteConfirmUiState(
val isLoading: Boolean = true,
val inviterName: String? = null,
val error: String? = null,
val navigateTo: String? = null,
val isConfirming: Boolean = false
)
@HiltViewModel
class InviteConfirmViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val authRepository: AuthRepository,
private val inviteRepository: InviteRepository,
private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository
) : ViewModel() {
private val inviteCode: String = savedStateHandle["inviteCode"] ?: ""
private var loadedInvite: Invite? = null
private val _uiState = MutableStateFlow(InviteConfirmUiState())
val uiState: StateFlow<InviteConfirmUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
inviteRepository.getInviteByCode(inviteCode)
.onSuccess { invite ->
loadedInvite = invite
val inviterName = invite?.let {
runCatching { userRepository.getUser(it.inviterUserId)?.displayName }.getOrNull()
}
_uiState.update { it.copy(isLoading = false, inviterName = inviterName ?: "your partner") }
}
.onFailure {
_uiState.update { it.copy(isLoading = false, error = "Couldn't load invite details. Please go back and try again.") }
}
}
}
fun confirmPairing() {
val acceptorId = authRepository.currentUserId ?: run {
_uiState.update { it.copy(error = "Not signed in.") }
return
}
val invite = loadedInvite ?: run {
_uiState.update { it.copy(error = "Invite not loaded yet.") }
return
}
_uiState.update { it.copy(isConfirming = true, error = null) }
viewModelScope.launch {
coupleRepository.createCouple(invite.inviterUserId, acceptorId, inviteCode)
.onSuccess { coupleId ->
inviteRepository.markAccepted(inviteCode, acceptorId, coupleId)
_uiState.update { it.copy(isConfirming = false, navigateTo = AppRoute.HOME) }
}
.onFailure { e ->
_uiState.update { it.copy(isConfirming = false, error = e.message ?: "Couldn't complete pairing. Please try again.") }
}
}
}
fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
fun dismissError() = _uiState.update { it.copy(error = null) }
}