feat: challenges, desire sync, how well, memory lane, play hub + viewmodel, this or that, wheel history

This commit is contained in:
null 2026-06-22 20:46:40 -05:00
parent 97acfaf702
commit 2121fe5562
8 changed files with 207 additions and 45 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Boolean> = entitlementChecker.isPremium()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
}

View File

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

View File

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