feat(memory-lane): edit/delete capsules, custom unlock date picker, error snackbar

This commit is contained in:
null 2026-06-23 10:11:25 -05:00
parent 9710bbc438
commit 755077c7ba
2 changed files with 261 additions and 47 deletions

View File

@ -115,4 +115,24 @@ class FirestoreCapsuleDataSource @Inject constructor(
suspend fun unlockCapsule(coupleId: String, capsuleId: String) { suspend fun unlockCapsule(coupleId: String, capsuleId: String) {
col(coupleId).document(capsuleId).update("status", "unlocked").await() col(coupleId).document(capsuleId).update("status", "unlocked").await()
} }
suspend fun updateCapsule(capsule: TimeCapsule) {
val aead = encryptionManager.aeadFor(capsule.coupleId)
?: throw IllegalStateException("Encryption key not ready. Please try again in a moment.")
val encTitle = fieldEncryptor.encrypt(capsule.title, aead, capsule.coupleId)
val encContent = fieldEncryptor.encrypt(capsule.content, aead, capsule.coupleId)
val encPrompt = fieldEncryptor.encryptNullable(capsule.promptUsed, aead, capsule.coupleId)
col(capsule.coupleId).document(capsule.id).update(
mapOf(
"title" to encTitle,
"content" to encContent,
"promptUsed" to encPrompt,
"unlockAt" to capsule.unlockAt
)
).await()
}
suspend fun deleteCapsule(coupleId: String, capsuleId: String) {
col(coupleId).document(capsuleId).delete().await()
}
} }

View File

@ -27,13 +27,19 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import app.closer.ui.components.CloserHeartLoader import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -41,13 +47,18 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -100,7 +111,7 @@ private val capsulePrompts = listOf(
// ── ViewModel ────────────────────────────────────────────────────────────────── // ── ViewModel ──────────────────────────────────────────────────────────────────
enum class MemoryLanePhase { LOADING, LIST, CREATE, DETAIL, ERROR } enum class MemoryLanePhase { LOADING, LIST, CREATE, EDIT, DETAIL, ERROR }
data class MemoryLaneUiState( data class MemoryLaneUiState(
val phase: MemoryLanePhase = MemoryLanePhase.LOADING, val phase: MemoryLanePhase = MemoryLanePhase.LOADING,
@ -113,7 +124,11 @@ data class MemoryLaneUiState(
val createContent: String = "", val createContent: String = "",
val selectedPreset: UnlockPreset = UnlockPreset.ONE_YEAR, val selectedPreset: UnlockPreset = UnlockPreset.ONE_YEAR,
val selectedPrompt: String? = null, val selectedPrompt: String? = null,
val customUnlockMs: Long? = null,
val showDatePicker: Boolean = false,
val showDeleteConfirm: Boolean = false,
val isSaving: Boolean = false, val isSaving: Boolean = false,
val isDeleting: Boolean = false,
val error: String? = null, val error: String? = null,
val hasPremium: Boolean = true, val hasPremium: Boolean = true,
val navigateTo: String? = null val navigateTo: String? = null
@ -184,6 +199,8 @@ class MemoryLaneViewModel @Inject constructor(
createContent = "", createContent = "",
selectedPreset = UnlockPreset.ONE_YEAR, selectedPreset = UnlockPreset.ONE_YEAR,
selectedPrompt = null, selectedPrompt = null,
customUnlockMs = null,
showDatePicker = false,
error = null error = null
) )
} }
@ -195,7 +212,10 @@ class MemoryLaneViewModel @Inject constructor(
fun updateTitle(v: String) = _uiState.update { it.copy(createTitle = v) } fun updateTitle(v: String) = _uiState.update { it.copy(createTitle = v) }
fun updateContent(v: String) = _uiState.update { it.copy(createContent = v) } fun updateContent(v: String) = _uiState.update { it.copy(createContent = v) }
fun selectPreset(p: UnlockPreset) = _uiState.update { it.copy(selectedPreset = p) } fun selectPreset(p: UnlockPreset) = _uiState.update { it.copy(selectedPreset = p, customUnlockMs = null) }
fun showDatePicker() = _uiState.update { it.copy(showDatePicker = true) }
fun hideDatePicker() = _uiState.update { it.copy(showDatePicker = false) }
fun selectCustomDate(epochMs: Long) = _uiState.update { it.copy(customUnlockMs = epochMs, showDatePicker = false) }
fun selectPrompt(p: String?) = _uiState.update { fun selectPrompt(p: String?) = _uiState.update {
it.copy(selectedPrompt = p, createContent = if (p != null) "" else it.createContent) it.copy(selectedPrompt = p, createContent = if (p != null) "" else it.createContent)
} }
@ -220,7 +240,7 @@ class MemoryLaneViewModel @Inject constructor(
title = state.createTitle.trim(), title = state.createTitle.trim(),
content = state.createContent.trim(), content = state.createContent.trim(),
promptUsed = state.selectedPrompt, promptUsed = state.selectedPrompt,
unlockAt = now + state.selectedPreset.ms, unlockAt = state.customUnlockMs ?: (now + state.selectedPreset.ms),
createdAt = now, createdAt = now,
status = "sealed" status = "sealed"
) )
@ -235,6 +255,67 @@ class MemoryLaneViewModel @Inject constructor(
} }
} }
fun openEdit(capsule: TimeCapsule) = _uiState.update {
it.copy(
phase = MemoryLanePhase.EDIT,
selectedCapsule = capsule,
createTitle = capsule.title,
createContent = capsule.content,
selectedPrompt = capsule.promptUsed,
customUnlockMs = capsule.unlockAt,
showDatePicker = false,
error = null
)
}
fun saveEdit() {
val state = _uiState.value
val capsule = state.selectedCapsule ?: return
val coupleId = state.coupleId ?: 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 updated = capsule.copy(
title = state.createTitle.trim(),
content = state.createContent.trim(),
promptUsed = state.selectedPrompt,
unlockAt = state.customUnlockMs ?: capsule.unlockAt
)
viewModelScope.launch {
runCatching { capsuleDataSource.updateCapsule(updated) }
.onSuccess { _uiState.update { it.copy(isSaving = false, phase = MemoryLanePhase.LIST, selectedCapsule = null) } }
.onFailure { e ->
Log.w(TAG, "Could not update capsule", e)
val msg = if (e is IllegalStateException) e.message ?: "Encryption not ready. Try again." else "Could not save. Check your connection."
_uiState.update { s -> s.copy(isSaving = false, error = msg) }
}
}
}
fun showDeleteConfirm() = _uiState.update { it.copy(showDeleteConfirm = true) }
fun dismissDeleteConfirm() = _uiState.update { it.copy(showDeleteConfirm = false) }
fun deleteCapsule() {
val state = _uiState.value
val capsule = state.selectedCapsule ?: return
val coupleId = state.coupleId ?: return
_uiState.update { it.copy(showDeleteConfirm = false, isDeleting = true) }
viewModelScope.launch {
runCatching { capsuleDataSource.deleteCapsule(coupleId, capsule.id) }
.onSuccess { _uiState.update { it.copy(isDeleting = false, phase = MemoryLanePhase.LIST, selectedCapsule = null) } }
.onFailure { e ->
Log.w(TAG, "Could not delete capsule", e)
_uiState.update { s -> s.copy(isDeleting = false, error = "Could not delete. Check your connection and try again.") }
}
}
}
fun dismissError() = _uiState.update { it.copy(error = null) } fun dismissError() = _uiState.update { it.copy(error = null) }
companion object { companion object {
@ -250,52 +331,97 @@ fun MemoryLaneScreen(
viewModel: MemoryLaneViewModel = hiltViewModel() viewModel: MemoryLaneViewModel = hiltViewModel()
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(state.navigateTo) { LaunchedEffect(state.navigateTo) {
state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() } state.navigateTo?.let { onNavigate(it); viewModel.onNavigated() }
} }
// Show snackbar only for DETAIL-phase errors (create/edit have inline error display).
LaunchedEffect(state.error, state.phase) {
val msg = state.error
if (state.phase == MemoryLanePhase.DETAIL && msg != null) {
snackbarHostState.showSnackbar(msg)
viewModel.dismissError()
}
}
Box( Scaffold(
modifier = Modifier snackbarHost = { SnackbarHost(snackbarHostState) },
.fillMaxSize() containerColor = Color.Transparent
.background(closerBackgroundBrush()) ) { padding ->
) { Box(
when (state.phase) { modifier = Modifier
MemoryLanePhase.LOADING -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { .fillMaxSize()
CloserHeartLoader() .padding(padding)
} .background(closerBackgroundBrush())
MemoryLanePhase.LIST -> if (!state.hasPremium) { ) {
MemoryLaneLockedScreen( when (state.phase) {
onBack = { onNavigate(AppRoute.PLAY) }, MemoryLanePhase.LOADING -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
onUnlock = { onNavigate(AppRoute.PAYWALL) } CloserHeartLoader()
}
MemoryLanePhase.LIST -> if (!state.hasPremium) {
MemoryLaneLockedScreen(
onBack = { onNavigate(AppRoute.PLAY) },
onUnlock = { onNavigate(AppRoute.PAYWALL) }
)
} else {
CapsuleListScreen(
capsules = state.capsules,
onBack = { onNavigate(AppRoute.PLAY) },
onNew = { viewModel.openCreate() },
onOpen = { viewModel.openDetail(it) }
)
}
MemoryLanePhase.CREATE -> CapsuleCreateScreen(
state = state,
isEditing = false,
onBack = { viewModel.backToList() },
onTitleChange = { viewModel.updateTitle(it) },
onContentChange = { viewModel.updateContent(it) },
onPresetSelect = { viewModel.selectPreset(it) },
onPromptSelect = { viewModel.selectPrompt(it) },
onShowDatePicker = { viewModel.showDatePicker() },
onDatePicked = { viewModel.selectCustomDate(it) },
onHideDatePicker = { viewModel.hideDatePicker() },
onSave = { viewModel.saveCapsule() }
) )
} else { MemoryLanePhase.EDIT -> CapsuleCreateScreen(
CapsuleListScreen( state = state,
capsules = state.capsules, isEditing = true,
onBack = { viewModel.backToList() },
onTitleChange = { viewModel.updateTitle(it) },
onContentChange = { viewModel.updateContent(it) },
onPresetSelect = { viewModel.selectPreset(it) },
onPromptSelect = { viewModel.selectPrompt(it) },
onShowDatePicker = { viewModel.showDatePicker() },
onDatePicked = { viewModel.selectCustomDate(it) },
onHideDatePicker = { viewModel.hideDatePicker() },
onSave = { viewModel.saveEdit() }
)
MemoryLanePhase.DETAIL -> {
val capsule = state.selectedCapsule!!
CapsuleDetailScreen(
capsule = capsule,
userId = state.userId ?: "",
onBack = { viewModel.backToList() },
onEdit = if (!capsule.isUnlocked && capsule.authorId == state.userId) {
{ viewModel.openEdit(capsule) }
} else null,
onDelete = if (capsule.authorId == state.userId) {
{ viewModel.showDeleteConfirm() }
} else null,
showDeleteConfirm = state.showDeleteConfirm,
isDeleting = state.isDeleting,
onConfirmDelete = { viewModel.deleteCapsule() },
onDismissDelete = { viewModel.dismissDeleteConfirm() }
)
}
MemoryLanePhase.ERROR -> MemoryLaneErrorScreen(
message = state.error ?: "Something went wrong.",
onBack = { onNavigate(AppRoute.PLAY) }, onBack = { onNavigate(AppRoute.PLAY) },
onNew = { viewModel.openCreate() }, onRetry = { viewModel.retry() }
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() }
)
MemoryLanePhase.ERROR -> MemoryLaneErrorScreen(
message = state.error ?: "Something went wrong.",
onBack = { onNavigate(AppRoute.PLAY) },
onRetry = { viewModel.retry() }
)
} }
} }
} }
@ -475,17 +601,39 @@ private fun CapsuleCard(capsule: TimeCapsule, onClick: () -> Unit) {
// ── Create ───────────────────────────────────────────────────────────────────── // ── Create ─────────────────────────────────────────────────────────────────────
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
private fun CapsuleCreateScreen( private fun CapsuleCreateScreen(
state: MemoryLaneUiState, state: MemoryLaneUiState,
isEditing: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
onTitleChange: (String) -> Unit, onTitleChange: (String) -> Unit,
onContentChange: (String) -> Unit, onContentChange: (String) -> Unit,
onPresetSelect: (UnlockPreset) -> Unit, onPresetSelect: (UnlockPreset) -> Unit,
onPromptSelect: (String?) -> Unit, onPromptSelect: (String?) -> Unit,
onShowDatePicker: () -> Unit,
onDatePicked: (Long) -> Unit,
onHideDatePicker: () -> Unit,
onSave: () -> Unit onSave: () -> Unit
) { ) {
if (state.showDatePicker) {
val pickerState = rememberDatePickerState(
initialSelectedDateMillis = state.customUnlockMs ?: (System.currentTimeMillis() + UnlockPreset.ONE_YEAR.ms)
)
DatePickerDialog(
onDismissRequest = onHideDatePicker,
confirmButton = {
TextButton(onClick = {
val ms = pickerState.selectedDateMillis
if (ms != null && ms > System.currentTimeMillis()) onDatePicked(ms)
else onHideDatePicker()
}) { Text("OK") }
},
dismissButton = { TextButton(onClick = onHideDatePicker) { Text("Cancel") } }
) {
DatePicker(state = pickerState)
}
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -501,7 +649,7 @@ private fun CapsuleCreateScreen(
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = MaterialTheme.colorScheme.onBackground) Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = MaterialTheme.colorScheme.onBackground)
} }
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text("New Capsule", style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onBackground) Text(if (isEditing) "Edit Capsule" else "New Capsule", style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onBackground)
} }
// Title field // Title field
@ -558,13 +706,13 @@ private fun CapsuleCreateScreen(
) )
) )
// Unlock date presets // Unlock date presets + custom date
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Open on", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Text("Open on", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
UnlockPreset.entries.forEach { preset -> UnlockPreset.entries.forEach { preset ->
FilterChip( FilterChip(
selected = state.selectedPreset == preset, selected = state.customUnlockMs == null && state.selectedPreset == preset,
onClick = { onPresetSelect(preset) }, onClick = { onPresetSelect(preset) },
label = { Text(preset.label, style = MaterialTheme.typography.labelSmall) }, label = { Text(preset.label, style = MaterialTheme.typography.labelSmall) },
colors = FilterChipDefaults.filterChipColors( colors = FilterChipDefaults.filterChipColors(
@ -573,9 +721,24 @@ private fun CapsuleCreateScreen(
) )
) )
} }
FilterChip(
selected = state.customUnlockMs != null,
onClick = onShowDatePicker,
label = {
Text(
if (state.customUnlockMs != null) formatDate(state.customUnlockMs) else "Custom date",
style = MaterialTheme.typography.labelSmall
)
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = CloserPalette.PurpleDeep.copy(alpha = 0.15f),
selectedLabelColor = CloserPalette.PurpleDeep
)
)
} }
val previewMs = state.customUnlockMs ?: (System.currentTimeMillis() + state.selectedPreset.ms)
Text( Text(
text = "Will open on ${formatDate(System.currentTimeMillis() + state.selectedPreset.ms)}", text = "Will open on ${formatDate(previewMs)}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@ -595,7 +758,7 @@ private fun CapsuleCreateScreen(
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep) colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) { ) {
if (state.isSaving) CloserHeartLoader(size = 22.dp) if (state.isSaving) CloserHeartLoader(size = 22.dp)
else Text("Seal the capsule 📦", color = Color.White, style = MaterialTheme.typography.labelLarge) else Text(if (isEditing) "Save changes" else "Seal the capsule 📦", color = Color.White, style = MaterialTheme.typography.labelLarge)
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@ -608,8 +771,28 @@ private fun CapsuleCreateScreen(
private fun CapsuleDetailScreen( private fun CapsuleDetailScreen(
capsule: TimeCapsule, capsule: TimeCapsule,
userId: String, userId: String,
onBack: () -> Unit onBack: () -> Unit,
onEdit: (() -> Unit)?,
onDelete: (() -> Unit)?,
showDeleteConfirm: Boolean,
isDeleting: Boolean,
onConfirmDelete: () -> Unit,
onDismissDelete: () -> Unit
) { ) {
if (showDeleteConfirm) {
AlertDialog(
onDismissRequest = onDismissDelete,
title = { Text("Delete capsule?") },
text = { Text("This cannot be undone. The capsule and its contents will be permanently removed.") },
confirmButton = {
TextButton(onClick = onConfirmDelete) { Text("Delete", color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton(onClick = onDismissDelete) { Text("Cancel") }
}
)
}
val unlocked = capsule.isUnlocked val unlocked = capsule.isUnlocked
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
@ -633,6 +816,17 @@ private fun CapsuleDetailScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
if (onEdit != null) {
IconButton(onClick = onEdit, enabled = !isDeleting) {
Icon(Icons.Filled.Edit, contentDescription = "Edit", tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(20.dp))
}
}
if (onDelete != null) {
IconButton(onClick = onDelete, enabled = !isDeleting) {
if (isDeleting) CloserHeartLoader(size = 20.dp)
else Icon(Icons.Filled.Delete, contentDescription = "Delete", tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f), modifier = Modifier.size(20.dp))
}
}
} }
} }