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.LOADING -> ChallengesLoadingScreen()
ChallengesPhase.PICK -> ChallengesPickScreen( ChallengesPhase.PICK -> ChallengesPickScreen(
onBack = { onNavigate(AppRoute.PLAY) }, onBack = { onNavigate(AppRoute.PLAY) },
onPick = { viewModel.startChallenge(it) } onPick = { viewModel.startChallenge(it) },
onPaywall = { onNavigate(AppRoute.PAYWALL) }
) )
ChallengesPhase.ACTIVE -> ChallengesActiveScreen( ChallengesPhase.ACTIVE -> ChallengesActiveScreen(
challenge = state.activeChallenge!!, challenge = state.activeChallenge!!,
@ -250,7 +251,8 @@ private fun ChallengesLoadingScreen() {
@Composable @Composable
private fun ChallengesPickScreen( private fun ChallengesPickScreen(
onBack: () -> Unit, onBack: () -> Unit,
onPick: (ConnectionChallenge) -> Unit onPick: (ConnectionChallenge) -> Unit,
onPaywall: () -> Unit
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
@ -289,7 +291,7 @@ private fun ChallengesPickScreen(
} }
items(ChallengesCatalog.all) { challenge -> items(ChallengesCatalog.all) { challenge ->
ChallengePickCard(challenge = challenge, onPick = onPick) ChallengePickCard(challenge = challenge, onPick = onPick, onPaywall = onPaywall)
} }
item { Spacer(Modifier.height(8.dp)) } item { Spacer(Modifier.height(8.dp)) }
@ -299,10 +301,11 @@ private fun ChallengesPickScreen(
@Composable @Composable
private fun ChallengePickCard( private fun ChallengePickCard(
challenge: ConnectionChallenge, challenge: ConnectionChallenge,
onPick: (ConnectionChallenge) -> Unit onPick: (ConnectionChallenge) -> Unit,
onPaywall: () -> Unit
) { ) {
Card( Card(
onClick = { if (!challenge.isPremium) onPick(challenge) }, onClick = { if (challenge.isPremium) onPaywall() else onPick(challenge) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),

View File

@ -309,9 +309,13 @@ class DesireSyncViewModel @Inject constructor(
fun quit() { fun quit() {
viewModelScope.launch { viewModelScope.launch {
// Bailed before submitting → cancel the session so the couple isn't locked out. if (!submitted) {
// After submitting → leave it active so the partner can still play and reveal. val cId = coupleId
if (!submitted) finishSession() if (cId != null) {
gameSessionManager.abandonSession(cId)
.onFailure { Log.d(TAG, "quit-abandon no-op: ${it.message}") }
}
}
_uiState.update { it.copy(navigateTo = AppRoute.PLAY) } _uiState.update { it.copy(navigateTo = AppRoute.PLAY) }
} }
} }

View File

@ -354,7 +354,13 @@ class HowWellViewModel @Inject constructor(
fun quit() { fun quit() {
viewModelScope.launch { 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) } _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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.core.billing.EntitlementChecker
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.data.remote.FirestoreCapsuleDataSource import app.closer.data.remote.FirestoreCapsuleDataSource
import app.closer.domain.model.TimeCapsule import app.closer.domain.model.TimeCapsule
@ -99,7 +100,7 @@ private val capsulePrompts = listOf(
// ── ViewModel ────────────────────────────────────────────────────────────────── // ── ViewModel ──────────────────────────────────────────────────────────────────
enum class MemoryLanePhase { LOADING, LIST, CREATE, DETAIL } enum class MemoryLanePhase { LOADING, LIST, CREATE, DETAIL, ERROR }
data class MemoryLaneUiState( data class MemoryLaneUiState(
val phase: MemoryLanePhase = MemoryLanePhase.LOADING, val phase: MemoryLanePhase = MemoryLanePhase.LOADING,
@ -113,14 +114,16 @@ data class MemoryLaneUiState(
val selectedPreset: UnlockPreset = UnlockPreset.ONE_YEAR, val selectedPreset: UnlockPreset = UnlockPreset.ONE_YEAR,
val selectedPrompt: String? = null, val selectedPrompt: String? = null,
val isSaving: Boolean = false, val isSaving: Boolean = false,
val error: String? = null val error: String? = null,
val hasPremium: Boolean = true
) )
@HiltViewModel @HiltViewModel
class MemoryLaneViewModel @Inject constructor( class MemoryLaneViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository, private val coupleRepository: CoupleRepository,
private val capsuleDataSource: FirestoreCapsuleDataSource private val capsuleDataSource: FirestoreCapsuleDataSource,
private val entitlementChecker: EntitlementChecker
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(MemoryLaneUiState()) private val _uiState = MutableStateFlow(MemoryLaneUiState())
@ -132,24 +135,44 @@ class MemoryLaneViewModel @Inject constructor(
private fun load() { private fun load() {
viewModelScope.launch { viewModelScope.launch {
val uid = authRepository.currentUserId ?: return@launch val hasPremium = entitlementChecker.hasPremium()
val couple = coupleRepository.getCoupleForUser(uid) ?: return@launch 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
}
_uiState.update { it.copy(coupleId = couple.id, userId = uid, hasPremium = true) }
runCatching {
capsuleDataSource.observeCapsules(couple.id).collect { capsules -> capsuleDataSource.observeCapsules(couple.id).collect { capsules ->
// Auto-unlock any capsules whose date has passed.
capsules.filter { it.status == "sealed" && System.currentTimeMillis() >= it.unlockAt } capsules.filter { it.status == "sealed" && System.currentTimeMillis() >= it.unlockAt }
.forEach { capsule -> .forEach { capsule ->
viewModelScope.launch { viewModelScope.launch {
runCatching { capsuleDataSource.unlockCapsule(couple.id, capsule.id) } 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 { fun openCreate() = _uiState.update {
it.copy( it.copy(
@ -233,12 +256,19 @@ fun MemoryLaneScreen(
MemoryLanePhase.LOADING -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { MemoryLanePhase.LOADING -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CloserHeartLoader() CloserHeartLoader()
} }
MemoryLanePhase.LIST -> CapsuleListScreen( MemoryLanePhase.LIST -> if (!state.hasPremium) {
MemoryLaneLockedScreen(
onBack = { onNavigate(AppRoute.PLAY) },
onUnlock = { onNavigate(AppRoute.PAYWALL) }
)
} else {
CapsuleListScreen(
capsules = state.capsules, capsules = state.capsules,
onBack = { onNavigate(AppRoute.PLAY) }, onBack = { onNavigate(AppRoute.PLAY) },
onNew = { viewModel.openCreate() }, onNew = { viewModel.openCreate() },
onOpen = { viewModel.openDetail(it) } onOpen = { viewModel.openDetail(it) }
) )
}
MemoryLanePhase.CREATE -> CapsuleCreateScreen( MemoryLanePhase.CREATE -> CapsuleCreateScreen(
state = state, state = state,
onBack = { viewModel.backToList() }, onBack = { viewModel.backToList() },
@ -253,10 +283,72 @@ fun MemoryLaneScreen(
userId = state.userId ?: "", userId = state.userId ?: "",
onBack = { viewModel.backToList() } 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 ─────────────────────────────────────────────────────────────────────── // ── List ───────────────────────────────────────────────────────────────────────
@Composable @Composable

View File

@ -35,8 +35,11 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.R import app.closer.R
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.ui.components.CategoryGlyph import app.closer.ui.components.CategoryGlyph
@ -52,14 +55,17 @@ import app.closer.ui.theme.closerPlayCardBrush
@Composable @Composable
fun PlayHubScreen( 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 @Composable
private fun PlayHubContent( private fun PlayHubContent(
onNavigate: (String) -> Unit onNavigate: (String) -> Unit,
hasPremium: Boolean = true
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@ -116,7 +122,7 @@ private fun PlayHubContent(
item { item {
DesireSyncCard( DesireSyncCard(
onClick = { onNavigate(AppRoute.DESIRE_SYNC) } onClick = { onNavigate(if (hasPremium) AppRoute.DESIRE_SYNC else AppRoute.PAYWALL) }
) )
} }
@ -128,7 +134,7 @@ private fun PlayHubContent(
item { item {
MemoryLaneCard( MemoryLaneCard(
onClick = { onNavigate(AppRoute.MEMORY_LANE) } onClick = { onNavigate(if (hasPremium) AppRoute.MEMORY_LANE else AppRoute.PAYWALL) }
) )
} }
@ -510,12 +516,41 @@ private fun MemoryLaneCard(
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = "Memory Lane", text = "Memory Lane",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface 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(
text = "Write a note to your future selves. It stays sealed until the date you choose.", text = "Write a note to your future selves. It stays sealed until the date you choose.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@ -706,5 +741,5 @@ private fun WheelGlyph(
@Preview @Preview
@Composable @Composable
fun PlayHubScreenPreview() { 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() { fun quit() {
viewModelScope.launch { viewModelScope.launch {
// Bailed before submitting → cancel the session so the couple isn't locked out. if (!submitted) {
// After submitting → leave it active so the partner can still play and reveal. val cId = coupleId
if (!submitted) finishSession() if (cId != null) {
gameSessionManager.abandonSession(cId)
.onFailure { Log.d(TAG, "quit-abandon no-op: ${it.message}") }
}
}
_uiState.update { it.copy(navigateTo = AppRoute.PLAY) } _uiState.update { it.copy(navigateTo = AppRoute.PLAY) }
} }
} }

View File

@ -66,7 +66,7 @@ fun WheelHistoryScreen(
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Session History") }, title = { Text("Past Games") },
navigationIcon = { navigationIcon = {
IconButton(onClick = { onNavigate("back") }) { IconButton(onClick = { onNavigate("back") }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")