diff --git a/app/src/main/java/com/couplesconnect/app/data/remote/FirestoreCoupleDataSource.kt b/app/src/main/java/com/couplesconnect/app/data/remote/FirestoreCoupleDataSource.kt new file mode 100644 index 00000000..baef45ab --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/data/remote/FirestoreCoupleDataSource.kt @@ -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) ?: 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) } + } +} diff --git a/app/src/main/java/com/couplesconnect/app/data/remote/FirestoreInviteDataSource.kt b/app/src/main/java/com/couplesconnect/app/data/remote/FirestoreInviteDataSource.kt new file mode 100644 index 00000000..74954d64 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/data/remote/FirestoreInviteDataSource.kt @@ -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" + } +} diff --git a/app/src/main/java/com/couplesconnect/app/data/repository/CoupleRepositoryImpl.kt b/app/src/main/java/com/couplesconnect/app/data/repository/CoupleRepositoryImpl.kt new file mode 100644 index 00000000..cb0cc295 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/data/repository/CoupleRepositoryImpl.kt @@ -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 = runCatching { + coupleDataSource.createCouple(inviterUserId, acceptorUserId, inviteCode) + } +} diff --git a/app/src/main/java/com/couplesconnect/app/data/repository/InviteRepositoryImpl.kt b/app/src/main/java/com/couplesconnect/app/data/repository/InviteRepositoryImpl.kt new file mode 100644 index 00000000..54522803 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/data/repository/InviteRepositoryImpl.kt @@ -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 = runCatching { + val code = dataSource.generateCode() + dataSource.createInvite(code, inviterUserId) + code + } + + override suspend fun getInviteByCode(code: String): Result = runCatching { + dataSource.getInviteByCode(code) + } + + override suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Result = runCatching { + dataSource.markAccepted(code, acceptorUserId, coupleId) + } +} diff --git a/app/src/main/java/com/couplesconnect/app/di/RepositoryModule.kt b/app/src/main/java/com/couplesconnect/app/di/RepositoryModule.kt index caf68c29..520330b3 100644 --- a/app/src/main/java/com/couplesconnect/app/di/RepositoryModule.kt +++ b/app/src/main/java/com/couplesconnect/app/di/RepositoryModule.kt @@ -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 diff --git a/app/src/main/java/com/couplesconnect/app/domain/repository/CoupleRepository.kt b/app/src/main/java/com/couplesconnect/app/domain/repository/CoupleRepository.kt new file mode 100644 index 00000000..8965b3a8 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/domain/repository/CoupleRepository.kt @@ -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 +} diff --git a/app/src/main/java/com/couplesconnect/app/domain/repository/InviteRepository.kt b/app/src/main/java/com/couplesconnect/app/domain/repository/InviteRepository.kt new file mode 100644 index 00000000..396a992c --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/domain/repository/InviteRepository.kt @@ -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 + suspend fun getInviteByCode(code: String): Result + suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Result +} diff --git a/app/src/main/java/com/couplesconnect/app/ui/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/couplesconnect/app/ui/onboarding/OnboardingViewModel.kt index 3f1e3f3f..21f2e70c 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/onboarding/OnboardingViewModel.kt @@ -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) } } } diff --git a/app/src/main/java/com/couplesconnect/app/ui/pairing/AcceptInviteScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/pairing/AcceptInviteScreen.kt index a08361c4..c2da35c3 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/pairing/AcceptInviteScreen.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/pairing/AcceptInviteScreen.kt @@ -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 -@Preview -@Composable -fun AcceptInviteScreenPreview() { - AcceptInviteScreen() + LaunchedEffect(state.navigateTo) { + state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() } + } + + 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 + ) + } + } + } } diff --git a/app/src/main/java/com/couplesconnect/app/ui/pairing/AcceptInviteViewModel.kt b/app/src/main/java/com/couplesconnect/app/ui/pairing/AcceptInviteViewModel.kt new file mode 100644 index 00000000..be6bd2b7 --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/ui/pairing/AcceptInviteViewModel.kt @@ -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 = _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" + } +} diff --git a/app/src/main/java/com/couplesconnect/app/ui/pairing/CreateInviteScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/pairing/CreateInviteScreen.kt index 94337885..078261a7 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/pairing/CreateInviteScreen.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/pairing/CreateInviteScreen.kt @@ -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 -@Preview -@Composable -fun CreateInviteScreenPreview() { - CreateInviteScreen() + 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 + ) + } + + Spacer(Modifier.height(32.dp)) + } + } + } } diff --git a/app/src/main/java/com/couplesconnect/app/ui/pairing/CreateInviteViewModel.kt b/app/src/main/java/com/couplesconnect/app/ui/pairing/CreateInviteViewModel.kt new file mode 100644 index 00000000..8739272e --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/ui/pairing/CreateInviteViewModel.kt @@ -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 = _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) } +} diff --git a/app/src/main/java/com/couplesconnect/app/ui/pairing/InviteConfirmScreen.kt b/app/src/main/java/com/couplesconnect/app/ui/pairing/InviteConfirmScreen.kt index 58c4bfd1..61ba79c3 100644 --- a/app/src/main/java/com/couplesconnect/app/ui/pairing/InviteConfirmScreen.kt +++ b/app/src/main/java/com/couplesconnect/app/ui/pairing/InviteConfirmScreen.kt @@ -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() } -@Preview -@Composable -fun InviteConfirmScreenPreview() { - InviteConfirmScreen(inviteCode = "ABC123") + 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 + ) + } + + Spacer(Modifier.height(32.dp)) + } + } + } } diff --git a/app/src/main/java/com/couplesconnect/app/ui/pairing/InviteConfirmViewModel.kt b/app/src/main/java/com/couplesconnect/app/ui/pairing/InviteConfirmViewModel.kt new file mode 100644 index 00000000..13b6710f --- /dev/null +++ b/app/src/main/java/com/couplesconnect/app/ui/pairing/InviteConfirmViewModel.kt @@ -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 = _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) } +}