diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index a0a3f1ee..af061fbe 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -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 diff --git a/app/src/main/java/app/closer/core/navigation/AppRoute.kt b/app/src/main/java/app/closer/core/navigation/AppRoute.kt index d1e63fb9..1f0a4b6c 100644 --- a/app/src/main/java/app/closer/core/navigation/AppRoute.kt +++ b/app/src/main/java/app/closer/core/navigation/AppRoute.kt @@ -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 ) diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt new file mode 100644 index 00000000..e23f0b18 --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt @@ -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> = 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() + } +} diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt index 550a7fd0..2bc8ca99 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt @@ -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} ─────────── diff --git a/app/src/main/java/app/closer/domain/model/TimeCapsule.kt b/app/src/main/java/app/closer/domain/model/TimeCapsule.kt new file mode 100644 index 00000000..428a63f1 --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/TimeCapsule.kt @@ -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 +} diff --git a/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt b/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt new file mode 100644 index 00000000..965a2a46 --- /dev/null +++ b/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt @@ -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 = 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 = _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, + 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" + } +} 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 ef99c965..7ff6d864 100644 --- a/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt +++ b/app/src/main/java/app/closer/ui/play/PlayHubScreen.kt @@ -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