feat: add Time Capsule feature with Firestore data source, model, and MemoryLane UI screens

This commit is contained in:
null 2026-06-18 04:14:12 -05:00
parent 4e871f8e4f
commit af06cb2123
7 changed files with 767 additions and 0 deletions

View File

@ -49,6 +49,7 @@ import app.closer.ui.dates.BucketListScreen
import app.closer.ui.paywall.PaywallScreen
import app.closer.ui.play.PlayHubScreen
import app.closer.ui.challenges.ConnectionChallengesScreen
import app.closer.ui.memorylane.MemoryLaneScreen
import app.closer.ui.desiresync.DesireSyncScreen
import app.closer.ui.howwell.HowWellScreen
import app.closer.ui.thisorthat.ThisOrThatScreen
@ -315,6 +316,9 @@ fun AppNavigation(
composable(route = AppRoute.CONNECTION_CHALLENGES) {
ConnectionChallengesScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.MEMORY_LANE) {
MemoryLaneScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.WAITING_FOR_PARTNER) {
WaitingForPartnerScreen(
onNavigate = navigateRoute

View File

@ -42,6 +42,7 @@ object AppRoute {
const val HOW_WELL = "how_well"
const val DESIRE_SYNC = "desire_sync"
const val CONNECTION_CHALLENGES = "connection_challenges"
const val MEMORY_LANE = "memory_lane"
const val WAITING_FOR_PARTNER = "waiting_for_partner"
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
@ -95,6 +96,7 @@ object AppRoute {
Definition(HOW_WELL, "How Well Do You Know Me", "play"),
Definition(DESIRE_SYNC, "Desire Sync", "play"),
Definition(CONNECTION_CHALLENGES, "Connection Challenges", "play"),
Definition(MEMORY_LANE, "Memory Lane", "play"),
Definition(WAITING_FOR_PARTNER, "Waiting for Partner", "play")
)
@ -144,6 +146,7 @@ object AppRoute {
RELATIONSHIP_SETTINGS,
DELETE_ACCOUNT,
CONNECTION_CHALLENGES,
MEMORY_LANE,
WAITING_FOR_PARTNER
)

View File

@ -0,0 +1,63 @@
package app.closer.data.remote
import app.closer.domain.model.TimeCapsule
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FirestoreCapsuleDataSource @Inject constructor(private val db: FirebaseFirestore) {
private fun col(coupleId: String) =
db.collection(FirestoreCollections.COUPLES)
.document(coupleId)
.collection(FirestoreCollections.Couples.CAPSULES)
fun observeCapsules(coupleId: String): Flow<List<TimeCapsule>> = callbackFlow {
val reg = col(coupleId)
.orderBy("createdAt", com.google.firebase.firestore.Query.Direction.DESCENDING)
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
trySend(snap.documents.mapNotNull { doc ->
runCatching {
TimeCapsule(
id = doc.id,
coupleId = coupleId,
authorId = doc.getString("authorId") ?: "",
title = doc.getString("title") ?: "",
content = doc.getString("content") ?: "",
promptUsed = doc.getString("promptUsed"),
unlockAt = doc.getLong("unlockAt") ?: 0L,
createdAt = doc.getLong("createdAt") ?: 0L,
status = doc.getString("status") ?: "sealed"
)
}.getOrNull()
})
}
awaitClose { reg.remove() }
}
suspend fun createCapsule(capsule: TimeCapsule): String {
val ref = col(capsule.coupleId).document()
ref.set(
mapOf(
"authorId" to capsule.authorId,
"title" to capsule.title,
"content" to capsule.content,
"promptUsed" to capsule.promptUsed,
"unlockAt" to capsule.unlockAt,
"createdAt" to capsule.createdAt,
"status" to "sealed"
)
).await()
return ref.id
}
suspend fun unlockCapsule(coupleId: String, capsuleId: String) {
col(coupleId).document(capsuleId).update("status", "unlocked").await()
}
}

View File

@ -31,6 +31,7 @@ object FirestoreCollections {
const val BUCKET_LIST = "bucket_list"
const val DAILY_QUESTION = "daily_question"
const val CHALLENGES = "challenges"
const val CAPSULES = "capsules"
}
// ── Subcollections under couples/{coupleId}/daily_question/{date} ───────────

View File

@ -0,0 +1,15 @@
package app.closer.domain.model
data class TimeCapsule(
val id: String = "",
val coupleId: String = "",
val authorId: String = "",
val title: String = "",
val content: String = "",
val promptUsed: String? = null,
val unlockAt: Long = 0L,
val createdAt: Long = 0L,
val status: String = "sealed" // "sealed" | "unlocked"
) {
val isUnlocked: Boolean get() = status == "unlocked" || System.currentTimeMillis() >= unlockAt
}

View File

@ -0,0 +1,616 @@
package app.closer.ui.memorylane
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
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.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
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.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute
import app.closer.data.remote.FirestoreCapsuleDataSource
import app.closer.domain.model.TimeCapsule
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush
import dagger.hilt.android.lifecycle.HiltViewModel
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
// ── Unlock presets ─────────────────────────────────────────────────────────────
enum class UnlockPreset(val label: String, val ms: Long) {
ONE_MONTH("1 month", 30L * 86_400_000),
THREE_MONTHS("3 months", 90L * 86_400_000),
SIX_MONTHS("6 months", 180L * 86_400_000),
ONE_YEAR("1 year", 365L * 86_400_000),
TWO_YEARS("2 years", 730L * 86_400_000)
}
private val capsulePrompts = listOf(
"What are you most proud of in your relationship right now?",
"Write a letter to your partner for them to read when this opens.",
"What do you hope has changed — and what do you hope has stayed the same?",
"Describe where you are in your relationship today, honestly.",
"What's something small about right now that you never want to forget?",
"What challenge are you facing together, and how are you handling it?",
"If your future self reads this, what do you most want them to know?"
)
// ── ViewModel ──────────────────────────────────────────────────────────────────
enum class MemoryLanePhase { LOADING, LIST, CREATE, DETAIL }
data class MemoryLaneUiState(
val phase: MemoryLanePhase = MemoryLanePhase.LOADING,
val capsules: List<TimeCapsule> = emptyList(),
val coupleId: String? = null,
val userId: String? = null,
val selectedCapsule: TimeCapsule? = null,
// create form
val createTitle: String = "",
val createContent: String = "",
val selectedPreset: UnlockPreset = UnlockPreset.ONE_YEAR,
val selectedPrompt: String? = null,
val isSaving: Boolean = false,
val error: String? = null
)
@HiltViewModel
class MemoryLaneViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val capsuleDataSource: FirestoreCapsuleDataSource
) : ViewModel() {
private val _uiState = MutableStateFlow(MemoryLaneUiState())
val uiState: StateFlow<MemoryLaneUiState> = _uiState.asStateFlow()
init {
load()
}
private fun load() {
viewModelScope.launch {
val uid = authRepository.currentUserId ?: return@launch
val couple = coupleRepository.getCoupleForUser(uid) ?: return@launch
_uiState.update { it.copy(coupleId = couple.id, userId = uid) }
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(phase = MemoryLanePhase.LIST, capsules = capsules) }
}
}
}
fun openCreate() = _uiState.update {
it.copy(
phase = MemoryLanePhase.CREATE,
createTitle = "",
createContent = "",
selectedPreset = UnlockPreset.ONE_YEAR,
selectedPrompt = null,
error = null
)
}
fun openDetail(capsule: TimeCapsule) =
_uiState.update { it.copy(phase = MemoryLanePhase.DETAIL, selectedCapsule = capsule) }
fun backToList() = _uiState.update { it.copy(phase = MemoryLanePhase.LIST, selectedCapsule = null) }
fun updateTitle(v: String) = _uiState.update { it.copy(createTitle = v) }
fun updateContent(v: String) = _uiState.update { it.copy(createContent = v) }
fun selectPreset(p: UnlockPreset) = _uiState.update { it.copy(selectedPreset = p) }
fun selectPrompt(p: String?) = _uiState.update {
it.copy(selectedPrompt = p, createContent = if (p != null) "" else it.createContent)
}
fun saveCapsule() {
val state = _uiState.value
val coupleId = state.coupleId ?: return
val userId = state.userId ?: return
if (state.createTitle.isBlank()) {
_uiState.update { it.copy(error = "Give your capsule a title.") }
return
}
if (state.createContent.isBlank()) {
_uiState.update { it.copy(error = "Write something inside.") }
return
}
_uiState.update { it.copy(isSaving = true, error = null) }
val now = System.currentTimeMillis()
val capsule = TimeCapsule(
coupleId = coupleId,
authorId = userId,
title = state.createTitle.trim(),
content = state.createContent.trim(),
promptUsed = state.selectedPrompt,
unlockAt = now + state.selectedPreset.ms,
createdAt = now,
status = "sealed"
)
viewModelScope.launch {
runCatching { capsuleDataSource.createCapsule(capsule) }
.onSuccess { _uiState.update { it.copy(isSaving = false, phase = MemoryLanePhase.LIST) } }
.onFailure {
Log.w(TAG, "Could not save capsule", it)
_uiState.update { s -> s.copy(isSaving = false, error = "Could not save. Try again.") }
}
}
}
fun dismissError() = _uiState.update { it.copy(error = null) }
companion object {
private const val TAG = "MemoryLaneViewModel"
}
}
// ── Screen ─────────────────────────────────────────────────────────────────────
@Composable
fun MemoryLaneScreen(
onNavigate: (String) -> Unit = {},
viewModel: MemoryLaneViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
Box(
modifier = Modifier
.fillMaxSize()
.background(closerBackgroundBrush())
) {
when (state.phase) {
MemoryLanePhase.LOADING -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = CloserPalette.PurpleDeep)
}
MemoryLanePhase.LIST -> CapsuleListScreen(
capsules = state.capsules,
onBack = { onNavigate(AppRoute.PLAY) },
onNew = { viewModel.openCreate() },
onOpen = { viewModel.openDetail(it) }
)
MemoryLanePhase.CREATE -> CapsuleCreateScreen(
state = state,
onBack = { viewModel.backToList() },
onTitleChange = { viewModel.updateTitle(it) },
onContentChange = { viewModel.updateContent(it) },
onPresetSelect = { viewModel.selectPreset(it) },
onPromptSelect = { viewModel.selectPrompt(it) },
onSave = { viewModel.saveCapsule() }
)
MemoryLanePhase.DETAIL -> CapsuleDetailScreen(
capsule = state.selectedCapsule!!,
userId = state.userId ?: "",
onBack = { viewModel.backToList() }
)
}
}
}
// ── List ───────────────────────────────────────────────────────────────────────
@Composable
private fun CapsuleListScreen(
capsules: List<TimeCapsule>,
onBack: () -> Unit,
onNew: () -> Unit,
onOpen: (TimeCapsule) -> Unit
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding(),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = MaterialTheme.colorScheme.onBackground)
}
Spacer(Modifier.width(4.dp))
Column(modifier = Modifier.weight(1f)) {
Text("Memory Lane", style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onBackground)
Text("Notes that open on a future date.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onNew) {
Surface(shape = RoundedCornerShape(12.dp), color = CloserPalette.PurpleDeep) {
Icon(Icons.Filled.Add, contentDescription = "New capsule", tint = Color.White, modifier = Modifier.padding(6.dp).size(20.dp))
}
}
}
}
if (capsules.isEmpty()) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 48.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("📦", style = MaterialTheme.typography.displayMedium)
Text("No capsules yet", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurface)
Text("Write a note to your future selves — it'll stay sealed until the date you choose.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
Spacer(Modifier.height(8.dp))
Button(onClick = onNew, shape = RoundedCornerShape(16.dp), colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)) {
Text("Write your first capsule", color = Color.White)
}
}
}
} else {
items(capsules) { capsule ->
CapsuleCard(capsule = capsule, onClick = { onOpen(capsule) })
}
item {
TextButton(onClick = onNew, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text("Add another capsule")
}
}
}
item { Spacer(Modifier.height(8.dp)) }
}
}
@Composable
private fun CapsuleCard(capsule: TimeCapsule, onClick: () -> Unit) {
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
val unlocked = capsule.isUnlocked
Surface(
shape = RoundedCornerShape(14.dp),
color = if (unlocked) CloserPalette.Evergreen.copy(alpha = 0.12f) else CloserPalette.PurpleDeep.copy(alpha = 0.10f),
modifier = Modifier.size(48.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = if (unlocked) Icons.Filled.LockOpen else Icons.Filled.Lock,
contentDescription = null,
tint = if (unlocked) CloserPalette.Evergreen else CloserPalette.PurpleDeep,
modifier = Modifier.size(22.dp)
)
}
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(capsule.title, style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (unlocked) {
Text("Opened ${formatDate(capsule.unlockAt)}", style = MaterialTheme.typography.bodySmall, color = CloserPalette.Evergreen)
} else {
Text("Opens ${countdown(capsule.unlockAt)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
// ── Create ─────────────────────────────────────────────────────────────────────
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CapsuleCreateScreen(
state: MemoryLaneUiState,
onBack: () -> Unit,
onTitleChange: (String) -> Unit,
onContentChange: (String) -> Unit,
onPresetSelect: (UnlockPreset) -> Unit,
onPromptSelect: (String?) -> Unit,
onSave: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Header
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = MaterialTheme.colorScheme.onBackground)
}
Spacer(Modifier.width(4.dp))
Text("New Capsule", style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onBackground)
}
// Title field
OutlinedTextField(
value = state.createTitle,
onValueChange = onTitleChange,
label = { Text("Title") },
placeholder = { Text("e.g. Letter to us") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = CloserPalette.PurpleDeep,
focusedLabelColor = CloserPalette.PurpleDeep
)
)
// Optional prompt selector
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Need a prompt?", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
capsulePrompts.forEach { prompt ->
val selected = state.selectedPrompt == prompt
FilterChip(
selected = selected,
onClick = { onPromptSelect(if (selected) null else prompt) },
label = { Text(prompt.take(36) + if (prompt.length > 36) "" else "", style = MaterialTheme.typography.labelSmall) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = CloserPalette.PurpleDeep.copy(alpha = 0.15f),
selectedLabelColor = CloserPalette.PurpleDeep
)
)
}
}
}
// Content field
if (state.selectedPrompt != null) {
Card(shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors(containerColor = CloserPalette.PurpleDeep.copy(alpha = 0.07f))) {
Text(state.selectedPrompt, modifier = Modifier.padding(14.dp), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
}
}
OutlinedTextField(
value = state.createContent,
onValueChange = onContentChange,
label = { Text(if (state.selectedPrompt != null) "Your answer" else "What do you want to remember?") },
minLines = 5,
maxLines = 12,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = CloserPalette.PurpleDeep,
focusedLabelColor = CloserPalette.PurpleDeep
)
)
// Unlock date presets
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Open on", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
UnlockPreset.entries.forEach { preset ->
FilterChip(
selected = state.selectedPreset == preset,
onClick = { onPresetSelect(preset) },
label = { Text(preset.label, style = MaterialTheme.typography.labelSmall) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = CloserPalette.PurpleDeep.copy(alpha = 0.15f),
selectedLabelColor = CloserPalette.PurpleDeep
)
)
}
}
Text(
text = "Will open on ${formatDate(System.currentTimeMillis() + state.selectedPreset.ms)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Error
if (state.error != null) {
Text(state.error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
// Save button
Button(
onClick = onSave,
enabled = !state.isSaving,
modifier = Modifier.fillMaxWidth().heightIn(min = 54.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) {
if (state.isSaving) CircularProgressIndicator(color = Color.White, modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
else Text("Seal the capsule 📦", color = Color.White, style = MaterialTheme.typography.labelLarge)
}
Spacer(Modifier.height(8.dp))
}
}
// ── Detail ─────────────────────────────────────────────────────────────────────
@Composable
private fun CapsuleDetailScreen(
capsule: TimeCapsule,
userId: String,
onBack: () -> Unit
) {
val unlocked = capsule.isUnlocked
LazyColumn(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding(),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
item {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = MaterialTheme.colorScheme.onBackground)
}
Spacer(Modifier.width(4.dp))
Column(modifier = Modifier.weight(1f)) {
Text(capsule.title, style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(
text = if (capsule.authorId == userId) "Written by you" else "Written by your partner",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
if (unlocked) {
item {
Surface(shape = RoundedCornerShape(999.dp), color = CloserPalette.Evergreen.copy(alpha = 0.12f)) {
Row(modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Icon(Icons.Filled.LockOpen, contentDescription = null, tint = CloserPalette.Evergreen, modifier = Modifier.size(14.dp))
Text("Opened ${formatDate(capsule.unlockAt)}", style = MaterialTheme.typography.labelSmall, color = CloserPalette.Evergreen, fontWeight = FontWeight.SemiBold)
}
}
}
if (capsule.promptUsed != null) {
item {
Card(shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors(containerColor = CloserPalette.PurpleDeep.copy(alpha = 0.07f))) {
Text(capsule.promptUsed, modifier = Modifier.padding(14.dp), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(modifier = Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(capsule.content, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, lineHeight = MaterialTheme.typography.bodyLarge.lineHeight)
Text("Written ${formatDate(capsule.createdAt)}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
} else {
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier.fillMaxWidth().padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(Icons.Filled.Lock, contentDescription = null, tint = CloserPalette.PurpleDeep.copy(alpha = 0.5f), modifier = Modifier.size(40.dp))
Text("Still sealed", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center)
Text(
text = "Opens ${countdown(capsule.unlockAt)}${formatDate(capsule.unlockAt)}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
}
}
item { Spacer(Modifier.height(8.dp)) }
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
private val dateFmt = SimpleDateFormat("MMM d, yyyy", Locale.US)
private fun formatDate(ms: Long): String = dateFmt.format(Date(ms))
private fun countdown(unlockAt: Long): String {
val diff = unlockAt - System.currentTimeMillis()
if (diff <= 0) return "now"
val days = TimeUnit.MILLISECONDS.toDays(diff)
return when {
days >= 365 -> "in ${days / 365} yr"
days >= 30 -> "in ${days / 30} mo"
days == 1L -> "tomorrow"
else -> "in $days days"
}
}

View File

@ -122,6 +122,12 @@ private fun PlayHubContent(
)
}
item {
MemoryLaneCard(
onClick = { onNavigate(AppRoute.MEMORY_LANE) }
)
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
@ -486,6 +492,65 @@ private fun ConnectionChallengesCard(
}
}
@Composable
private fun MemoryLaneCard(
onClick: () -> Unit
) {
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 132.dp),
shape = RoundedCornerShape(24.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(18.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = RoundedCornerShape(18.dp),
color = CloserPalette.Romantic.copy(alpha = 0.12f),
modifier = Modifier.size(52.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = "📦",
style = MaterialTheme.typography.headlineSmall
)
}
}
Column(
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
)
Text(
text = "Write a note to your future selves. It stays sealed until the date you choose.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null,
tint = CloserPalette.Romantic,
modifier = Modifier.size(18.dp)
)
}
}
}
@Composable
private fun FeaturedPlayCard(
onClick: () -> Unit