feat: add Time Capsule feature with Firestore data source, model, and MemoryLane UI screens
This commit is contained in:
parent
4e871f8e4f
commit
af06cb2123
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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} ───────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue