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) {
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.automirrored.filled.ArrowBack
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.LockOpen
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
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.FilterChipDefaults
import androidx.compose.material3.Icon
@ -41,13 +47,18 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -100,7 +111,7 @@ private val capsulePrompts = listOf(
// ── ViewModel ──────────────────────────────────────────────────────────────────
enum class MemoryLanePhase { LOADING, LIST, CREATE, DETAIL, ERROR }
enum class MemoryLanePhase { LOADING, LIST, CREATE, EDIT, DETAIL, ERROR }
data class MemoryLaneUiState(
val phase: MemoryLanePhase = MemoryLanePhase.LOADING,
@ -113,7 +124,11 @@ data class MemoryLaneUiState(
val createContent: String = "",
val selectedPreset: UnlockPreset = UnlockPreset.ONE_YEAR,
val selectedPrompt: String? = null,
val customUnlockMs: Long? = null,
val showDatePicker: Boolean = false,
val showDeleteConfirm: Boolean = false,
val isSaving: Boolean = false,
val isDeleting: Boolean = false,
val error: String? = null,
val hasPremium: Boolean = true,
val navigateTo: String? = null
@ -184,6 +199,8 @@ class MemoryLaneViewModel @Inject constructor(
createContent = "",
selectedPreset = UnlockPreset.ONE_YEAR,
selectedPrompt = null,
customUnlockMs = null,
showDatePicker = false,
error = null
)
}
@ -195,7 +212,10 @@ class MemoryLaneViewModel @Inject constructor(
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 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 {
it.copy(selectedPrompt = p, createContent = if (p != null) "" else it.createContent)
}
@ -220,7 +240,7 @@ class MemoryLaneViewModel @Inject constructor(
title = state.createTitle.trim(),
content = state.createContent.trim(),
promptUsed = state.selectedPrompt,
unlockAt = now + state.selectedPreset.ms,
unlockAt = state.customUnlockMs ?: (now + state.selectedPreset.ms),
createdAt = now,
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) }
companion object {
@ -250,14 +331,28 @@ fun MemoryLaneScreen(
viewModel: MemoryLaneViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(state.navigateTo) {
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()
}
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Color.Transparent
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.background(closerBackgroundBrush())
) {
when (state.phase) {
@ -279,18 +374,48 @@ fun MemoryLaneScreen(
}
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() }
)
MemoryLanePhase.DETAIL -> CapsuleDetailScreen(
capsule = state.selectedCapsule!!,
userId = state.userId ?: "",
onBack = { viewModel.backToList() }
MemoryLanePhase.EDIT -> CapsuleCreateScreen(
state = state,
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) },
@ -299,6 +424,7 @@ fun MemoryLaneScreen(
}
}
}
}
// ── Locked ─────────────────────────────────────────────────────────────────────
@ -475,17 +601,39 @@ private fun CapsuleCard(capsule: TimeCapsule, onClick: () -> Unit) {
// ── Create ─────────────────────────────────────────────────────────────────────
@OptIn(ExperimentalLayoutApi::class)
@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class)
@Composable
private fun CapsuleCreateScreen(
state: MemoryLaneUiState,
isEditing: Boolean,
onBack: () -> Unit,
onTitleChange: (String) -> Unit,
onContentChange: (String) -> Unit,
onPresetSelect: (UnlockPreset) -> Unit,
onPromptSelect: (String?) -> Unit,
onShowDatePicker: () -> Unit,
onDatePicked: (Long) -> Unit,
onHideDatePicker: () -> 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(
modifier = Modifier
.fillMaxSize()
@ -501,7 +649,7 @@ private fun CapsuleCreateScreen(
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)
Text(if (isEditing) "Edit Capsule" else "New Capsule", style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onBackground)
}
// Title field
@ -558,13 +706,13 @@ private fun CapsuleCreateScreen(
)
)
// Unlock date presets
// Unlock date presets + custom date
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,
selected = state.customUnlockMs == null && state.selectedPreset == preset,
onClick = { onPresetSelect(preset) },
label = { Text(preset.label, style = MaterialTheme.typography.labelSmall) },
colors = FilterChipDefaults.filterChipColors(
@ -573,9 +721,24 @@ private fun CapsuleCreateScreen(
)
)
}
}
FilterChip(
selected = state.customUnlockMs != null,
onClick = onShowDatePicker,
label = {
Text(
text = "Will open on ${formatDate(System.currentTimeMillis() + state.selectedPreset.ms)}",
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 = "Will open on ${formatDate(previewMs)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -595,7 +758,7 @@ private fun CapsuleCreateScreen(
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) {
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))
@ -608,8 +771,28 @@ private fun CapsuleCreateScreen(
private fun CapsuleDetailScreen(
capsule: TimeCapsule,
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
LazyColumn(
modifier = Modifier
@ -633,6 +816,17 @@ private fun CapsuleDetailScreen(
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))
}
}
}
}