From 2108d489145af144191ba1b5d990e98a532939bf Mon Sep 17 00:00:00 2001 From: null Date: Mon, 22 Jun 2026 20:46:40 -0500 Subject: [PATCH] feat: challenges, desire sync, how well, memory lane, play hub + viewmodel, this or that, wheel history --- .../challenges/ConnectionChallengesScreen.kt | 13 +- .../closer/ui/desiresync/DesireSyncScreen.kt | 10 +- .../app/closer/ui/howwell/HowWellScreen.kt | 8 +- .../closer/ui/memorylane/MemoryLaneScreen.kt | 134 +++++++++++++++--- .../java/app/closer/ui/play/PlayHubScreen.kt | 57 ++++++-- .../app/closer/ui/play/PlayHubViewModel.kt | 18 +++ .../closer/ui/thisorthat/ThisOrThatScreen.kt | 10 +- .../app/closer/ui/wheel/WheelHistoryScreen.kt | 2 +- 8 files changed, 207 insertions(+), 45 deletions(-) create mode 100644 app/src/main/java/app/closer/ui/play/PlayHubViewModel.kt diff --git a/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt b/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt index 5df9724e..70bdca8e 100644 --- a/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt +++ b/app/src/main/java/app/closer/ui/challenges/ConnectionChallengesScreen.kt @@ -224,7 +224,8 @@ fun ConnectionChallengesScreen( ChallengesPhase.LOADING -> ChallengesLoadingScreen() ChallengesPhase.PICK -> ChallengesPickScreen( onBack = { onNavigate(AppRoute.PLAY) }, - onPick = { viewModel.startChallenge(it) } + onPick = { viewModel.startChallenge(it) }, + onPaywall = { onNavigate(AppRoute.PAYWALL) } ) ChallengesPhase.ACTIVE -> ChallengesActiveScreen( challenge = state.activeChallenge!!, @@ -250,7 +251,8 @@ private fun ChallengesLoadingScreen() { @Composable private fun ChallengesPickScreen( onBack: () -> Unit, - onPick: (ConnectionChallenge) -> Unit + onPick: (ConnectionChallenge) -> Unit, + onPaywall: () -> Unit ) { LazyColumn( modifier = Modifier @@ -289,7 +291,7 @@ private fun ChallengesPickScreen( } items(ChallengesCatalog.all) { challenge -> - ChallengePickCard(challenge = challenge, onPick = onPick) + ChallengePickCard(challenge = challenge, onPick = onPick, onPaywall = onPaywall) } item { Spacer(Modifier.height(8.dp)) } @@ -299,10 +301,11 @@ private fun ChallengesPickScreen( @Composable private fun ChallengePickCard( challenge: ConnectionChallenge, - onPick: (ConnectionChallenge) -> Unit + onPick: (ConnectionChallenge) -> Unit, + onPaywall: () -> Unit ) { Card( - onClick = { if (!challenge.isPremium) onPick(challenge) }, + onClick = { if (challenge.isPremium) onPaywall() else onPick(challenge) }, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), diff --git a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt index 2b166645..6f0fbf8d 100644 --- a/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt +++ b/app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt @@ -309,9 +309,13 @@ class DesireSyncViewModel @Inject constructor( fun quit() { viewModelScope.launch { - // Bailed before submitting → cancel the session so the couple isn't locked out. - // After submitting → leave it active so the partner can still play and reveal. - if (!submitted) finishSession() + if (!submitted) { + val cId = coupleId + if (cId != null) { + gameSessionManager.abandonSession(cId) + .onFailure { Log.d(TAG, "quit-abandon no-op: ${it.message}") } + } + } _uiState.update { it.copy(navigateTo = AppRoute.PLAY) } } } diff --git a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt index 9916fc32..d346cb08 100644 --- a/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt +++ b/app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt @@ -354,7 +354,13 @@ class HowWellViewModel @Inject constructor( fun quit() { viewModelScope.launch { - if (!submitted) finishSession() + if (!submitted) { + val cId = coupleId + if (cId != null) { + gameSessionManager.abandonSession(cId) + .onFailure { Log.d(TAG, "quit-abandon no-op: ${it.message}") } + } + } _uiState.update { it.copy(navigateTo = AppRoute.PLAY) } } } diff --git a/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt b/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt index bd14b58b..ff32d25b 100644 --- a/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt +++ b/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.closer.core.billing.EntitlementChecker import app.closer.core.navigation.AppRoute import app.closer.data.remote.FirestoreCapsuleDataSource import app.closer.domain.model.TimeCapsule @@ -99,7 +100,7 @@ private val capsulePrompts = listOf( // ── ViewModel ────────────────────────────────────────────────────────────────── -enum class MemoryLanePhase { LOADING, LIST, CREATE, DETAIL } +enum class MemoryLanePhase { LOADING, LIST, CREATE, DETAIL, ERROR } data class MemoryLaneUiState( val phase: MemoryLanePhase = MemoryLanePhase.LOADING, @@ -113,14 +114,16 @@ data class MemoryLaneUiState( val selectedPreset: UnlockPreset = UnlockPreset.ONE_YEAR, val selectedPrompt: String? = null, val isSaving: Boolean = false, - val error: String? = null + val error: String? = null, + val hasPremium: Boolean = true ) @HiltViewModel class MemoryLaneViewModel @Inject constructor( private val authRepository: AuthRepository, private val coupleRepository: CoupleRepository, - private val capsuleDataSource: FirestoreCapsuleDataSource + private val capsuleDataSource: FirestoreCapsuleDataSource, + private val entitlementChecker: EntitlementChecker ) : ViewModel() { private val _uiState = MutableStateFlow(MemoryLaneUiState()) @@ -132,25 +135,45 @@ class MemoryLaneViewModel @Inject constructor( private fun load() { viewModelScope.launch { - val uid = authRepository.currentUserId ?: return@launch - val couple = coupleRepository.getCoupleForUser(uid) ?: return@launch + val hasPremium = entitlementChecker.hasPremium() + if (!hasPremium) { + _uiState.update { it.copy(phase = MemoryLanePhase.LIST, hasPremium = false) } + return@launch + } - _uiState.update { it.copy(coupleId = couple.id, userId = uid) } + val uid = authRepository.currentUserId ?: run { + _uiState.update { it.copy(phase = MemoryLanePhase.ERROR, error = "Sign in to view Memory Lane.") } + return@launch + } + val couple = coupleRepository.getCoupleForUser(uid) ?: run { + _uiState.update { it.copy(phase = MemoryLanePhase.ERROR, error = "Link up with a partner to start creating capsules.") } + return@launch + } - capsuleDataSource.observeCapsules(couple.id).collect { capsules -> - // Auto-unlock any capsules whose date has passed. - capsules.filter { it.status == "sealed" && System.currentTimeMillis() >= it.unlockAt } - .forEach { capsule -> - viewModelScope.launch { - runCatching { capsuleDataSource.unlockCapsule(couple.id, capsule.id) } + _uiState.update { it.copy(coupleId = couple.id, userId = uid, hasPremium = true) } + + runCatching { + capsuleDataSource.observeCapsules(couple.id).collect { capsules -> + capsules.filter { it.status == "sealed" && System.currentTimeMillis() >= it.unlockAt } + .forEach { capsule -> + viewModelScope.launch { + runCatching { capsuleDataSource.unlockCapsule(couple.id, capsule.id) } + } } - } - - _uiState.update { it.copy(phase = MemoryLanePhase.LIST, capsules = capsules) } + _uiState.update { it.copy(phase = MemoryLanePhase.LIST, capsules = capsules) } + } + }.onFailure { e -> + Log.w(TAG, "Failed to observe capsules", e) + _uiState.update { it.copy(phase = MemoryLanePhase.ERROR, error = "Couldn't load your capsules. Try again.") } } } } + fun retry() { + _uiState.update { it.copy(phase = MemoryLanePhase.LOADING, error = null) } + load() + } + fun openCreate() = _uiState.update { it.copy( phase = MemoryLanePhase.CREATE, @@ -233,12 +256,19 @@ fun MemoryLaneScreen( MemoryLanePhase.LOADING -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CloserHeartLoader() } - MemoryLanePhase.LIST -> CapsuleListScreen( - capsules = state.capsules, - onBack = { onNavigate(AppRoute.PLAY) }, - onNew = { viewModel.openCreate() }, - onOpen = { viewModel.openDetail(it) } - ) + MemoryLanePhase.LIST -> if (!state.hasPremium) { + MemoryLaneLockedScreen( + onBack = { onNavigate(AppRoute.PLAY) }, + onUnlock = { onNavigate(AppRoute.PAYWALL) } + ) + } else { + CapsuleListScreen( + capsules = state.capsules, + onBack = { onNavigate(AppRoute.PLAY) }, + onNew = { viewModel.openCreate() }, + onOpen = { viewModel.openDetail(it) } + ) + } MemoryLanePhase.CREATE -> CapsuleCreateScreen( state = state, onBack = { viewModel.backToList() }, @@ -253,10 +283,72 @@ fun MemoryLaneScreen( userId = state.userId ?: "", onBack = { viewModel.backToList() } ) + MemoryLanePhase.ERROR -> MemoryLaneErrorScreen( + message = state.error ?: "Something went wrong.", + onBack = { onNavigate(AppRoute.PLAY) }, + onRetry = { viewModel.retry() } + ) } } } +// ── Locked ───────────────────────────────────────────────────────────────────── + +@Composable +private fun MemoryLaneLockedScreen(onBack: () -> Unit, onUnlock: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .navigationBarsPadding() + .padding(horizontal = 28.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Surface(shape = RoundedCornerShape(50.dp), color = CloserPalette.Romantic.copy(alpha = 0.12f)) { + Icon( + Icons.Filled.Lock, + contentDescription = null, + modifier = Modifier.padding(20.dp).size(32.dp), + tint = CloserPalette.Romantic + ) + } + Spacer(Modifier.height(20.dp)) + Text("Memory Lane is a premium feature", style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center) + Spacer(Modifier.height(10.dp)) + Text("Write notes to your future selves — sealed until the date you choose.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + Spacer(Modifier.height(28.dp)) + Button(onClick = onUnlock, modifier = Modifier.fillMaxWidth().heightIn(min = 54.dp), shape = RoundedCornerShape(18.dp), colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.Romantic)) { + Text("Unlock premium", color = Color.White, style = MaterialTheme.typography.labelLarge) + } + TextButton(onClick = onBack) { Text("Go back") } + } +} + +// ── Error ────────────────────────────────────────────────────────────────────── + +@Composable +private fun MemoryLaneErrorScreen(message: String, onBack: () -> Unit, onRetry: () -> Unit) { + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .navigationBarsPadding() + .padding(horizontal = 28.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text("📦", style = MaterialTheme.typography.displayMedium) + Spacer(Modifier.height(16.dp)) + Text(message, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + Spacer(Modifier.height(24.dp)) + Button(onClick = onRetry, shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)) { + Text("Try again", color = Color.White) + } + TextButton(onClick = onBack) { Text("Go back") } + } +} + // ── List ─────────────────────────────────────────────────────────────────────── @Composable diff --git a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt index e4e85fae..cb52f663 100644 --- a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt +++ b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt @@ -35,8 +35,11 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import app.closer.R import app.closer.core.navigation.AppRoute import app.closer.ui.components.CategoryGlyph @@ -52,14 +55,17 @@ import app.closer.ui.theme.closerPlayCardBrush @Composable fun PlayHubScreen( - onNavigate: (String) -> Unit = {} + onNavigate: (String) -> Unit = {}, + viewModel: PlayHubViewModel = hiltViewModel() ) { - PlayHubContent(onNavigate = onNavigate) + val hasPremium by viewModel.hasPremium.collectAsState() + PlayHubContent(onNavigate = onNavigate, hasPremium = hasPremium) } @Composable private fun PlayHubContent( - onNavigate: (String) -> Unit + onNavigate: (String) -> Unit, + hasPremium: Boolean = true ) { Box( modifier = Modifier @@ -116,7 +122,7 @@ private fun PlayHubContent( item { DesireSyncCard( - onClick = { onNavigate(AppRoute.DESIRE_SYNC) } + onClick = { onNavigate(if (hasPremium) AppRoute.DESIRE_SYNC else AppRoute.PAYWALL) } ) } @@ -128,7 +134,7 @@ private fun PlayHubContent( item { MemoryLaneCard( - onClick = { onNavigate(AppRoute.MEMORY_LANE) } + onClick = { onNavigate(if (hasPremium) AppRoute.MEMORY_LANE else AppRoute.PAYWALL) } ) } @@ -511,11 +517,40 @@ private fun MemoryLaneCard( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp) ) { - Text( - text = "Memory Lane", - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onSurface - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Memory Lane", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface + ) + Surface( + shape = RoundedCornerShape(CloserRadii.Pill), + color = CloserPalette.Romantic.copy(alpha = 0.12f) + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = CloserPalette.Romantic, + modifier = Modifier.size(13.dp) + ) + Text( + text = "Premium", + style = MaterialTheme.typography.labelSmall, + color = CloserPalette.Romantic, + fontWeight = FontWeight.SemiBold + ) + } + } + } Text( text = "Write a note to your future selves. It stays sealed until the date you choose.", style = MaterialTheme.typography.bodySmall, @@ -706,5 +741,5 @@ private fun WheelGlyph( @Preview @Composable fun PlayHubScreenPreview() { - PlayHubContent(onNavigate = {}) + PlayHubContent(onNavigate = {}, hasPremium = true) } diff --git a/app/src/main/java/app/closer/ui/play/PlayHubViewModel.kt b/app/src/main/java/app/closer/ui/play/PlayHubViewModel.kt new file mode 100644 index 00000000..95a30071 --- /dev/null +++ b/app/src/main/java/app/closer/ui/play/PlayHubViewModel.kt @@ -0,0 +1,18 @@ +package app.closer.ui.play + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.closer.core.billing.EntitlementChecker +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn + +@HiltViewModel +class PlayHubViewModel @Inject constructor( + entitlementChecker: EntitlementChecker +) : ViewModel() { + val hasPremium: StateFlow = entitlementChecker.isPremium() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) +} diff --git a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt index 2d1f6f22..ea3a0a69 100644 --- a/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt +++ b/app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt @@ -356,9 +356,13 @@ class ThisOrThatViewModel @Inject constructor( fun quit() { viewModelScope.launch { - // Bailed before submitting → cancel the session so the couple isn't locked out. - // After submitting → leave it active so the partner can still play and reveal. - if (!submitted) finishSession() + if (!submitted) { + val cId = coupleId + if (cId != null) { + gameSessionManager.abandonSession(cId) + .onFailure { Log.d(TAG, "quit-abandon no-op: ${it.message}") } + } + } _uiState.update { it.copy(navigateTo = AppRoute.PLAY) } } } diff --git a/app/src/main/java/app/closer/ui/wheel/WheelHistoryScreen.kt b/app/src/main/java/app/closer/ui/wheel/WheelHistoryScreen.kt index 40e38109..b6abdbd5 100644 --- a/app/src/main/java/app/closer/ui/wheel/WheelHistoryScreen.kt +++ b/app/src/main/java/app/closer/ui/wheel/WheelHistoryScreen.kt @@ -66,7 +66,7 @@ fun WheelHistoryScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Session History") }, + title = { Text("Past Games") }, navigationIcon = { IconButton(onClick = { onNavigate("back") }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")