From 755077c7ba8d92bcc7abc6256200513643def5e1 Mon Sep 17 00:00:00 2001 From: null Date: Tue, 23 Jun 2026 10:11:25 -0500 Subject: [PATCH] feat(memory-lane): edit/delete capsules, custom unlock date picker, error snackbar --- .../data/remote/FirestoreCapsuleDataSource.kt | 20 ++ .../closer/ui/memorylane/MemoryLaneScreen.kt | 288 +++++++++++++++--- 2 files changed, 261 insertions(+), 47 deletions(-) diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt index 2955f65f..84f344a7 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCapsuleDataSource.kt @@ -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() + } } diff --git a/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt b/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt index 00426e98..e21fbd20 100644 --- a/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt +++ b/app/src/main/java/app/closer/ui/memorylane/MemoryLaneScreen.kt @@ -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,52 +331,97 @@ 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() + } + } - Box( - modifier = Modifier - .fillMaxSize() - .background(closerBackgroundBrush()) - ) { - when (state.phase) { - MemoryLanePhase.LOADING -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CloserHeartLoader() - } - MemoryLanePhase.LIST -> if (!state.hasPremium) { - MemoryLaneLockedScreen( - onBack = { onNavigate(AppRoute.PLAY) }, - onUnlock = { onNavigate(AppRoute.PAYWALL) } + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + containerColor = Color.Transparent + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .background(closerBackgroundBrush()) + ) { + when (state.phase) { + MemoryLanePhase.LOADING -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + 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 { - CapsuleListScreen( - capsules = state.capsules, + 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) }, - onNew = { viewModel.openCreate() }, - onOpen = { viewModel.openDetail(it) } + onRetry = { viewModel.retry() } ) } - 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 ───────────────────────────────────────────────────────────────────── -@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( + 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(System.currentTimeMillis() + state.selectedPreset.ms)}", + 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)) + } + } } }