feat(memory-lane): edit/delete capsules, custom unlock date picker, error snackbar
This commit is contained in:
parent
9710bbc438
commit
755077c7ba
|
|
@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue