From 11f9649dd73a6cf6b0b40d4e5074b8d97bee9e4e Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Tue, 23 Jun 2026 11:03:02 +0200 Subject: [PATCH] M3: rebuild task create/edit form in Calendula's card language Replace the boxed-text-field form with the family's tonal-card layout: Close + filled SAVE top bar, a borderless headline title with a list-colour accent bar, a "When" card (all-day switch + tappable start/due rows), a tappable list card and reminder card that open OptionCard dialogs, and segmented-button priority. Optional Description / Priority / Reminder sections unfold from a "More fields" picker (Calendula's disclosure): TaskFormField enum + populatedFields() auto-reveal fields that already carry a value, and a persisted defaultEditFields pref decides which start open (the settings UI to edit it lands with M6). New shared InlineTextField + OptionCard; date/time conversion helpers in DateTimeField promoted to internal. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../floret/data/prefs/SettingsPrefs.kt | 12 + .../jeanlucmakiola/floret/domain/TaskForm.kt | 15 + .../floret/ui/common/DateTimeField.kt | 6 +- .../floret/ui/common/InlineTextField.kt | 83 ++ .../floret/ui/common/OptionCard.kt | 95 +++ .../floret/ui/edit/TaskEditScreen.kt | 779 ++++++++++++++---- .../floret/ui/edit/TaskEditViewModel.kt | 97 ++- app/src/main/res/values/strings.xml | 5 + 8 files changed, 898 insertions(+), 194 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/floret/ui/common/InlineTextField.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/floret/ui/common/OptionCard.kt diff --git a/app/src/main/java/de/jeanlucmakiola/floret/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/floret/data/prefs/SettingsPrefs.kt index 051caa3..1d89041 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/data/prefs/SettingsPrefs.kt @@ -7,6 +7,8 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.core.stringSetPreferencesKey +import de.jeanlucmakiola.floret.domain.TaskFormField import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -21,6 +23,8 @@ data class Settings( val defaultListId: Long? = null, /** Default minutes before due to remind; 0 = at due time. */ val reminderLeadMinutes: Int = 0, + /** Optional edit-form fields shown by default; the rest sit behind "More fields". */ + val defaultEditFields: Set = emptySet(), ) /** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */ @@ -35,6 +39,9 @@ class SettingsPrefs @Inject constructor( dynamicColor = p[DYNAMIC_COLOR] ?: true, defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 }, reminderLeadMinutes = p[REMINDER_LEAD] ?: 0, + defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty() + .mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() } + .toSet(), ) } @@ -46,10 +53,15 @@ class SettingsPrefs @Inject constructor( suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes } + suspend fun setDefaultEditFields(fields: Set) = dataStore.edit { + it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name } + } + private companion object { val THEME_MODE = stringPreferencesKey("theme_mode") val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color") val DEFAULT_LIST_ID = longPreferencesKey("default_list_id") val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes") + val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields") } } diff --git a/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskForm.kt b/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskForm.kt index c570ad6..825c441 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/domain/TaskForm.kt @@ -30,3 +30,18 @@ data class TaskForm( } enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE } + +/** + * Optional edit-form sections that can be shown or tucked behind "More fields" + * (the core Title / List / When are always visible). Which render by default is + * a setting; the rest unfold on demand. Declaring these as an enum keeps the + * disclosure generic, so adding a field later is one entry plus its card. + */ +enum class TaskFormField { Description, Priority, Reminder } + +/** The optional fields that already carry a value — auto-revealed when editing. */ +fun TaskForm.populatedFields(): Set = buildSet { + if (!description.isNullOrBlank()) add(TaskFormField.Description) + if (priority != Priority.NONE) add(TaskFormField.Priority) + if (reminderMinutesBeforeDue != null) add(TaskFormField.Reminder) +} diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/common/DateTimeField.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/DateTimeField.kt index 6819ea8..d9915ba 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/ui/common/DateTimeField.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/DateTimeField.kt @@ -40,13 +40,13 @@ import kotlin.time.Instant private val zone: ZoneId get() = ZoneId.systemDefault() -private fun Instant.toLocalDate(): LocalDate = +internal fun Instant.toLocalDate(): LocalDate = java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate() -private fun Instant.toLocalTime(): LocalTime = +internal fun Instant.toLocalTime(): LocalTime = java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime() -private fun localToInstant(date: LocalDate, time: LocalTime): Instant = +internal fun localToInstant(date: LocalDate, time: LocalTime): Instant = Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli()) /** diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/common/InlineTextField.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/InlineTextField.kt new file mode 100644 index 0000000..fa4da56 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/InlineTextField.kt @@ -0,0 +1,83 @@ +package de.jeanlucmakiola.floret.ui.common + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp + +/** + * The app's borderless text input: no underline, no outline, just the tonal + * card behind it. Floret uses this inside a tonal [androidx.compose.material3.Surface] + * for the edit form (title + cards), so anything that takes text reads as one + * family rather than a boxed Material field. Ported from Calendula so the two + * apps share one input style. + */ +@Composable +fun InlineTextField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + textStyle: TextStyle = MaterialTheme.typography.titleMedium, + singleLine: Boolean = true, + minLines: Int = 1, + keyboardType: KeyboardType = KeyboardType.Text, + capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences, + imeAction: ImeAction = ImeAction.Default, + /** Invoked when the IME action key (e.g. Done) is pressed. */ + onImeAction: (() -> Unit)? = null, +) { + val resolvedStyle = textStyle.copy( + color = if (textStyle.color.isSpecified) { + textStyle.color + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = resolvedStyle, + singleLine = singleLine, + minLines = minLines, + keyboardOptions = KeyboardOptions( + keyboardType = keyboardType, + capitalization = capitalization, + imeAction = imeAction, + ), + keyboardActions = onImeAction?.let { action -> + KeyboardActions(onAny = { action() }) + } ?: KeyboardActions.Default, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + decorationBox = { innerTextField -> + Box { + if (value.isEmpty()) { + // Clearly fainter than typed text, so a hint never reads as + // prefilled content. + Text( + text = placeholder, + style = resolvedStyle, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + innerTextField() + } + }, + modifier = modifier, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/common/OptionCard.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/OptionCard.kt new file mode 100644 index 0000000..dfad3da --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/common/OptionCard.kt @@ -0,0 +1,95 @@ +package de.jeanlucmakiola.floret.ui.common + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +/** + * The app's standard pick in a selection dialog: a full-width tonal card, + * optionally with a leading icon and a supporting line; the selected option is + * highlighted. Stack with 8dp gaps inside an AlertDialog — the only sanctioned + * selection-modal style (no radio rows, no bare text lists). Ported from + * Calendula so the families' dialogs read identically. + */ +@Composable +fun OptionCard( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: ImageVector? = null, + /** Icon tint override, e.g. a list colour; unspecified follows selection. */ + iconTint: Color = Color.Unspecified, + supportingText: String? = null, + selected: Boolean = false, + /** Label colour override, e.g. primary for an emphasised entry. */ + labelColor: Color = Color.Unspecified, +) { + val contentColor = if (selected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + } + Surface( + onClick = onClick, + color = if (selected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + }, + shape = RoundedCornerShape(12.dp), + modifier = modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = when { + iconTint.isSpecified -> iconTint + selected -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(12.dp)) + } + Column { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = if (labelColor.isSpecified) labelColor else contentColor, + ) + if (supportingText != null) { + Text( + text = supportingText, + style = MaterialTheme.typography.bodySmall, + color = if (selected) { + MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditScreen.kt index 6c40957..c9e1cff 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditScreen.kt @@ -1,55 +1,102 @@ package de.jeanlucmakiola.floret.ui.edit +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width 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.rounded.ArrowBack -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material.icons.automirrored.rounded.Notes +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.ArrowDropDown +import androidx.compose.material.icons.rounded.Checklist +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material.icons.rounded.Clear +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Flag +import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MenuAnchorType -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.floret.R import de.jeanlucmakiola.floret.domain.Priority import de.jeanlucmakiola.floret.domain.TaskFormError -import de.jeanlucmakiola.floret.ui.common.DateTimeField +import de.jeanlucmakiola.floret.domain.TaskFormField +import de.jeanlucmakiola.floret.domain.TaskList +import de.jeanlucmakiola.floret.ui.common.InlineTextField +import de.jeanlucmakiola.floret.ui.common.OptionCard +import de.jeanlucmakiola.floret.ui.common.formatDate +import de.jeanlucmakiola.floret.ui.common.formatTime +import de.jeanlucmakiola.floret.ui.common.localToInstant +import de.jeanlucmakiola.floret.ui.common.pastelize +import de.jeanlucmakiola.floret.ui.common.toLocalDate +import de.jeanlucmakiola.floret.ui.common.toLocalTime import de.jeanlucmakiola.floret.ui.tasklist.priorityLabel +import java.time.LocalTime +import java.time.ZoneOffset +import kotlin.time.Instant /** - * Create / edit form. Title, description, list, start / due date-time with an - * all-day toggle, priority and a reminder offset — validated through the M1 - * [TaskEditViewModel] / [de.jeanlucmakiola.floret.domain.TaskForm]. The VM flips - * `saved` once the write lands, which pops us back. + * Create / edit form, in the family's tonal-card language (mirrors Calendula's + * event editor): a borderless headline title with a list-colour accent bar, a + * "When" card with the all-day toggle and tappable schedule rows, a tappable + * list card, and the optional Description / Priority / Reminder sections that + * unfold from "More fields" (which ones start open is a setting). Validated + * through the M1 [TaskEditViewModel] / [de.jeanlucmakiola.floret.domain.TaskForm]; + * the VM flips `saved` once the write lands, which pops us back. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -67,160 +114,607 @@ fun TaskEditScreen( modifier = modifier, topBar = { TopAppBar( - title = { - Text( - stringResource( - if (state.isNew) R.string.new_task_title else R.string.edit_task_title, - ), - ) - }, + title = {}, navigationIcon = { IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(R.string.back), - ) + Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close)) } }, actions = { - IconButton(onClick = viewModel::save, enabled = !state.loading) { - Icon(Icons.Rounded.Check, contentDescription = stringResource(R.string.save)) - } + Button( + onClick = viewModel::save, + enabled = !state.loading, + modifier = Modifier.padding(end = 12.dp), + ) { Text(stringResource(R.string.save)) } }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), ) }, ) { inner -> if (state.loading) return@Scaffold + EditContent( + state = state, + viewModel = viewModel, + modifier = Modifier.padding(inner), + ) + } +} - Column( +private enum class PickerTarget { Start, Due } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EditContent( + state: TaskEditUiState, + viewModel: TaskEditViewModel, + modifier: Modifier = Modifier, +) { + val dark = isSystemInDarkTheme() + val selectedList = state.lists.firstOrNull { it.id == state.listId } + // The accent ties the form to the detail screen's language: the bar under + // the title takes the target list's colour. + val accent = selectedList?.let { pastelize(it.color, dark) } ?: MaterialTheme.colorScheme.primary + val gap = 12.dp + + var pickerTarget by remember { mutableStateOf(null) } + var showListPicker by rememberSaveable { mutableStateOf(false) } + var showReminderPicker by rememberSaveable { mutableStateOf(false) } + var showFieldPicker by rememberSaveable { mutableStateOf(false) } + + Column( + modifier = modifier + // Shrink the scroll viewport by the keyboard so the focused field + // scrolls into view above the IME instead of being hidden. + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp), + ) { + // Title: borderless headline + accent bar, mirroring the detail screen. + InlineField( + value = state.title, + onValueChange = viewModel::onTitleChange, + placeholder = stringResource(R.string.edit_title_hint), + textStyle = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + ) + Spacer(Modifier.height(10.dp)) + Box( modifier = Modifier - .padding(inner) - // Shrink the scroll viewport by the keyboard so the focused - // field scrolls into view above the IME instead of being hidden. - .imePadding() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + .width(48.dp) + .height(3.dp) + .background(accent, RoundedCornerShape(2.dp)), + ) + if (TaskFormError.BLANK_TITLE in state.errors) { + Spacer(Modifier.height(6.dp)) + FieldError(R.string.error_blank_title) + } + + Spacer(Modifier.height(20.dp)) + + // "When" card: the icon centres on the all-day row (header); the start / + // due rows continue below in the same text column. + EditCard( + icon = Icons.Rounded.Schedule, + iconContentDescription = stringResource(R.string.edit_when), + header = { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.edit_all_day), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + Switch(checked = state.isAllDay, onCheckedChange = viewModel::onAllDayChange) + } + }, ) { - OutlinedTextField( - value = state.title, - onValueChange = viewModel::onTitleChange, - label = { Text(stringResource(R.string.field_title)) }, - singleLine = true, - isError = TaskFormError.BLANK_TITLE in state.errors, - supportingText = errorText(TaskFormError.BLANK_TITLE in state.errors, R.string.error_blank_title), - modifier = Modifier.fillMaxWidth(), - ) - - OutlinedTextField( - value = state.description, - onValueChange = viewModel::onDescriptionChange, - label = { Text(stringResource(R.string.field_description)) }, - modifier = Modifier.fillMaxWidth(), - ) - - ListPicker( - lists = state.lists, - selectedId = state.listId, - isError = TaskFormError.NO_LIST in state.errors, - onSelect = viewModel::onListChange, - ) - - // All-day toggle - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(stringResource(R.string.edit_all_day), style = MaterialTheme.typography.bodyLarge) - Switch(checked = state.isAllDay, onCheckedChange = viewModel::onAllDayChange) - } - - DateTimeField( + Spacer(Modifier.height(4.dp)) + ScheduleRow( label = stringResource(R.string.edit_start_label), value = state.start, allDay = state.isAllDay, - onChange = viewModel::onStartChange, + onPick = { pickerTarget = PickerTarget.Start }, + onClear = { viewModel.onStartChange(null) }, ) - DateTimeField( + ScheduleRow( label = stringResource(R.string.edit_due_label), value = state.due, allDay = state.isAllDay, - onChange = viewModel::onDueChange, + onPick = { pickerTarget = PickerTarget.Due }, + onClear = { viewModel.onDueChange(null) }, + isError = TaskFormError.DUE_BEFORE_START in state.errors, ) if (TaskFormError.DUE_BEFORE_START in state.errors) { + Spacer(Modifier.height(2.dp)) FieldError(R.string.error_due_before_start) } + } - // Priority - Column { + Spacer(Modifier.height(gap)) + + // List card — tap anywhere to pick the target list. + EditCard( + icon = Icons.Rounded.Checklist, + iconContentDescription = stringResource(R.string.edit_list_label), + iconTint = accent, + onClick = { showListPicker = true }.takeIf { state.lists.isNotEmpty() }, + ) { + Text( + text = selectedList?.name ?: stringResource(R.string.error_no_list), + style = MaterialTheme.typography.titleMedium, + color = if (selectedList == null) MaterialTheme.colorScheme.error else Color.Unspecified, + ) + selectedList?.accountName?.takeIf { it.isNotBlank() }?.let { account -> Text( - stringResource(R.string.edit_priority_label), - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(start = 4.dp, bottom = 6.dp), + text = account, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { - Priority.entries.forEachIndexed { index, priority -> - SegmentedButton( - selected = state.priority == priority, - onClick = { viewModel.onPriorityChange(priority) }, - shape = SegmentedButtonDefaults.itemShape(index, Priority.entries.size), - ) { Text(priorityLabel(priority)) } + } + } + + // Optional sections: which start open is a setting; the rest unfold + // behind the "More fields" button below. + OptionalFormSection(visible = TaskFormField.Description in state.visibleFields) { + Spacer(Modifier.height(gap)) + EditCard( + icon = Icons.AutoMirrored.Rounded.Notes, + iconContentDescription = stringResource(R.string.field_description), + iconAtTop = true, + ) { + InlineField( + value = state.description, + onValueChange = viewModel::onDescriptionChange, + placeholder = stringResource(R.string.field_description), + singleLine = false, + minLines = 3, + ) + } + } + + OptionalFormSection(visible = TaskFormField.Priority in state.visibleFields) { + Spacer(Modifier.height(gap)) + // Not an EditCard: the four buttons need the card's full width, so + // the label sits on its own row above rather than in the indented + // icon-gutter column (where "Medium" would wrap to a second line). + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Rounded.Flag, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(16.dp)) + Text( + text = stringResource(R.string.edit_priority_label), + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(Modifier.height(12.dp)) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + Priority.entries.forEachIndexed { index, priority -> + SegmentedButton( + selected = state.priority == priority, + onClick = { viewModel.onPriorityChange(priority) }, + shape = SegmentedButtonDefaults.itemShape(index, Priority.entries.size), + // Drop the selected-check icon; with four buttons + // its reserved width is what crowds the labels. + icon = {}, + ) { + Text( + text = priorityLabel(priority), + maxLines = 1, + style = MaterialTheme.typography.labelLarge, + ) + } + } } } } + } - ReminderPicker( - minutes = state.reminderMinutesBeforeDue, - onSelect = viewModel::onReminderChange, - ) - if (TaskFormError.REMINDER_WITHOUT_DUE in state.errors) { - FieldError(R.string.error_reminder_without_due) + OptionalFormSection(visible = TaskFormField.Reminder in state.visibleFields) { + Spacer(Modifier.height(gap)) + EditCard( + icon = Icons.Rounded.Notifications, + iconContentDescription = null, + onClick = { showReminderPicker = true }, + ) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(reminderOptionFor(state.reminderMinutesBeforeDue).labelRes), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.edit_reminder_label), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.Rounded.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (TaskFormError.REMINDER_WITHOUT_DUE in state.errors) { + Spacer(Modifier.height(2.dp)) + FieldError(R.string.error_reminder_without_due) + } } + } - if (state.saveFailed) FieldError(R.string.edit_save_failed) + OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) { + Spacer(Modifier.height(20.dp)) + TextButton( + onClick = { showFieldPicker = true }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.edit_more_fields)) + } + } + + if (state.saveFailed) { + Spacer(Modifier.height(gap)) + FieldError(R.string.edit_save_failed) + } + } + + pickerTarget?.let { target -> + val current = if (target == PickerTarget.Start) state.start else state.due + DateTimePickerFlow( + initial = current, + allDay = state.isAllDay, + onResult = { + if (target == PickerTarget.Start) viewModel.onStartChange(it) else viewModel.onDueChange(it) + pickerTarget = null + }, + onDismiss = { pickerTarget = null }, + ) + } + + if (showListPicker) { + ListPickerDialog( + lists = state.lists, + selectedId = state.listId, + onSelect = { viewModel.onListChange(it); showListPicker = false }, + onDismiss = { showListPicker = false }, + ) + } + + if (showReminderPicker) { + ReminderPickerDialog( + selected = state.reminderMinutesBeforeDue, + onSelect = { viewModel.onReminderChange(it); showReminderPicker = false }, + onDismiss = { showReminderPicker = false }, + ) + } + + if (showFieldPicker) { + FieldPickerDialog( + hiddenFields = state.hiddenFields, + onSelect = { viewModel.revealField(it); showFieldPicker = false }, + onDismiss = { showFieldPicker = false }, + ) + } +} + +/** + * Wrapper for an optional form section: fields added via "More fields" spring + * open instead of popping in. (Initially-visible sections render without + * animating on the first frame they're added.) + */ +@Composable +private fun OptionalFormSection(visible: Boolean, content: @Composable ColumnScope.() -> Unit) { + AnimatedVisibility( + visible = visible, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column(modifier = Modifier.fillMaxWidth(), content = content) + } +} + +/** + * One info card mirroring the detail screen's card: tonal container, leading + * icon in the gutter, value to the right. Optionally clickable as a whole. + */ +@Composable +private fun EditCard( + icon: ImageVector, + iconContentDescription: String?, + iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant, + onClick: (() -> Unit)? = null, + // Multiline-input cards align the icon with the field's first text line. + iconAtTop: Boolean = false, + // Tall cards pass their first row here: the icon centres on it, and + // [content] continues below, indented to the same text column. + header: (@Composable ColumnScope.() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + val shape = RoundedCornerShape(16.dp) + val color = MaterialTheme.colorScheme.surfaceContainerHigh + val inner: @Composable () -> Unit = { + if (header != null) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(icon, iconContentDescription, tint = iconTint, modifier = Modifier.size(24.dp)) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f), content = header) + } + Column(modifier = Modifier.padding(start = 40.dp), content = content) + } + } else { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = iconContentDescription, + tint = iconTint, + // 4dp mirrors InlineField's vertical padding, so a + // top-aligned icon centres on the first text line. + modifier = Modifier + .padding(top = if (iconAtTop) 4.dp else 0.dp) + .size(24.dp), + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f), content = content) + } + } + } + if (onClick != null) { + Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { inner() } + } else { + Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { inner() } + } +} + +/** + * Borderless input used inside the cards (and as the headline title) — a thin + * wrapper over the shared [InlineTextField] so the form and the rest of the app + * share one input style. + */ +@Composable +private fun InlineField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + textStyle: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.titleMedium, + singleLine: Boolean = true, + minLines: Int = 1, +) { + InlineTextField( + value = value, + onValueChange = onValueChange, + placeholder = placeholder, + textStyle = textStyle, + singleLine = singleLine, + minLines = minLines, + ) +} + +/** + * One schedule row: label, then the tappable date and (unless all-day) time — + * or "Set" when empty. Tapping runs the date → time picker flow; a clear + * affordance appears once a value is set. Tappable values read as links + * (primary); an error flips them to the error colour. + */ +@Composable +private fun ScheduleRow( + label: String, + value: Instant?, + allDay: Boolean, + onPick: () -> Unit, + onClear: () -> Unit, + isError: Boolean = false, +) { + val valueColor = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + if (value == null) { + Text( + text = stringResource(R.string.edit_set), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp), + ) + } else { + Text( + text = value.formatDate(), + style = MaterialTheme.typography.titleMedium, + color = valueColor, + modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp), + ) + if (!allDay) { + Text( + text = value.formatTime(), + style = MaterialTheme.typography.titleMedium, + color = valueColor, + modifier = Modifier.clickable(onClick = onPick).padding(vertical = 8.dp, horizontal = 6.dp), + ) + } + IconButton(onClick = onClear, modifier = Modifier.size(40.dp)) { + Icon( + Icons.Rounded.Clear, + contentDescription = stringResource(R.string.edit_clear), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } } } } +private fun nowInstant(): Instant = Instant.fromEpochMilliseconds(System.currentTimeMillis()) + +/** Pick a date, then (unless all-day) a time; emits the combined instant once. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ListPicker( - lists: List, - selectedId: Long?, - isError: Boolean, - onSelect: (Long) -> Unit, +private fun DateTimePickerFlow( + initial: Instant?, + allDay: Boolean, + onResult: (Instant) -> Unit, + onDismiss: () -> Unit, ) { - var expanded by remember { mutableStateOf(false) } - val selectedName = lists.firstOrNull { it.id == selectedId }?.name.orEmpty() + var pendingDate by remember { mutableStateOf(null) } + var showTime by remember { mutableStateOf(false) } - ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { - OutlinedTextField( - value = selectedName, - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(R.string.edit_list_label)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - isError = isError, - supportingText = errorText(isError, R.string.error_no_list), - modifier = Modifier - .fillMaxWidth() - .menuAnchor(MenuAnchorType.PrimaryNotEditable), + if (!showTime) { + val initialMillis = (initial ?: nowInstant()) + .toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() + val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis) + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { + val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton } + val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate() + if (allDay) { + onResult(localToInstant(date, LocalTime.MIDNIGHT)) + } else { + pendingDate = date + showTime = true + } + }) { Text(stringResource(android.R.string.ok)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) { DatePicker(state = dateState) } + } else { + val base = initial ?: nowInstant() + val timeState = rememberTimePickerState( + initialHour = base.toLocalTime().hour, + initialMinute = base.toLocalTime().minute, + ) + AlertDialog( + onDismissRequest = onDismiss, + text = { TimePicker(state = timeState) }, + confirmButton = { + TextButton(onClick = { + val date = pendingDate ?: return@TextButton + onResult(localToInstant(date, LocalTime.of(timeState.hour, timeState.minute))) + }) { Text(stringResource(android.R.string.ok)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, ) - ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - lists.forEach { list -> - DropdownMenuItem( - text = { Text(list.name) }, - onClick = { - onSelect(list.id) - expanded = false - }, - ) - } - } } } +@Composable +private fun ListPickerDialog( + lists: List, + selectedId: Long?, + onSelect: (Long) -> Unit, + onDismiss: () -> Unit, +) { + val dark = isSystemInDarkTheme() + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.edit_list_label)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + lists.forEach { list -> + OptionCard( + label = list.name, + onClick = { onSelect(list.id) }, + icon = Icons.Rounded.Circle, + iconTint = pastelize(list.color, dark), + supportingText = list.accountName.takeIf { it.isNotBlank() }, + selected = list.id == selectedId, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +@Composable +private fun ReminderPickerDialog( + selected: Int?, + onSelect: (Int?) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.edit_reminder_label)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + reminderOptions.forEach { option -> + OptionCard( + label = stringResource(option.labelRes), + onClick = { onSelect(option.minutes) }, + selected = option.minutes == selected, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +/** Picks one hidden optional section to add to the form. */ +@Composable +private fun FieldPickerDialog( + hiddenFields: List, + onSelect: (TaskFormField) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.edit_more_fields)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + hiddenFields.forEach { field -> + OptionCard( + label = stringResource(fieldLabel(field)), + onClick = { onSelect(field) }, + icon = fieldIcon(field), + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +private fun fieldLabel(field: TaskFormField): Int = when (field) { + TaskFormField.Description -> R.string.field_description + TaskFormField.Priority -> R.string.edit_priority_label + TaskFormField.Reminder -> R.string.edit_reminder_label +} + +private fun fieldIcon(field: TaskFormField): ImageVector = when (field) { + TaskFormField.Description -> Icons.AutoMirrored.Rounded.Notes + TaskFormField.Priority -> Icons.Rounded.Flag + TaskFormField.Reminder -> Icons.Rounded.Notifications +} + private data class ReminderOption(val labelRes: Int, val minutes: Int?) private val reminderOptions = listOf( @@ -233,52 +727,15 @@ private val reminderOptions = listOf( ReminderOption(R.string.reminder_1_day, 1_440), ) -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ReminderPicker(minutes: Int?, onSelect: (Int?) -> Unit) { - var expanded by remember { mutableStateOf(false) } - val current = reminderOptions.firstOrNull { it.minutes == minutes } ?: reminderOptions.first() - - ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { - OutlinedTextField( - value = stringResource(current.labelRes), - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(R.string.edit_reminder_label)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - modifier = Modifier - .fillMaxWidth() - .menuAnchor(MenuAnchorType.PrimaryNotEditable), - ) - ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - reminderOptions.forEach { option -> - DropdownMenuItem( - text = { Text(stringResource(option.labelRes)) }, - onClick = { - onSelect(option.minutes) - expanded = false - }, - ) - } - } - } -} - -/** A supporting-text lambda that shows [messageRes] only when [show]. */ -@Composable -private fun errorText(show: Boolean, messageRes: Int): (@Composable () -> Unit)? = - if (show) { - { Text(stringResource(messageRes)) } - } else { - null - } +private fun reminderOptionFor(minutes: Int?): ReminderOption = + reminderOptions.firstOrNull { it.minutes == minutes } ?: reminderOptions.first() @Composable private fun FieldError(messageRes: Int) { Text( text = stringResource(messageRes), color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(start = 4.dp), ) } diff --git a/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditViewModel.kt index 14cccbc..d0293a5 100644 --- a/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/floret/ui/edit/TaskEditViewModel.kt @@ -9,7 +9,9 @@ import de.jeanlucmakiola.floret.data.tasks.TasksRepository import de.jeanlucmakiola.floret.domain.Priority import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.floret.domain.TaskFormError +import de.jeanlucmakiola.floret.domain.TaskFormField import de.jeanlucmakiola.floret.domain.TaskList +import de.jeanlucmakiola.floret.domain.populatedFields import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -36,6 +38,10 @@ data class TaskEditUiState( val parentId: Long? = null, val reminderMinutesBeforeDue: Int? = null, val lists: List = emptyList(), + /** Optional sections currently shown (core Title / List / When are always on). */ + val visibleFields: Set = emptySet(), + /** Optional sections still tucked behind "More fields". */ + val hiddenFields: List = emptyList(), val errors: Set = emptySet(), val saveFailed: Boolean = false, val saved: Boolean = false, @@ -53,21 +59,28 @@ class TaskEditViewModel @Inject constructor( private var editingTaskId: Long? = null + /** Sections shown by default (a setting); the rest unfold via [revealField]. */ + private var defaultFields: Set = emptySet() + /** Start a fresh task, optionally pre-selecting a list / parent. */ fun bindNew(presetListId: Long? = null, parentId: Long? = null) { editingTaskId = null viewModelScope.launch { + val settings = settingsPrefs.settings.first() + defaultFields = settings.defaultEditFields val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() } val defaultList = presetListId - ?: settingsPrefs.settings.first().defaultListId + ?: settings.defaultListId ?: lists.firstOrNull { !it.isLocal }?.id ?: lists.firstOrNull()?.id - _state.value = TaskEditUiState( - loading = false, - isNew = true, - listId = defaultList, - parentId = parentId, - lists = lists, + _state.value = withFields( + TaskEditUiState( + loading = false, + isNew = true, + listId = defaultList, + parentId = parentId, + lists = lists, + ), ) } } @@ -76,28 +89,49 @@ class TaskEditViewModel @Inject constructor( fun bindEdit(taskId: Long) { editingTaskId = taskId viewModelScope.launch { + defaultFields = settingsPrefs.settings.first().defaultEditFields val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() } val task = runCatching { repository.taskDetail(taskId).first()?.task }.getOrNull() if (task == null) { _state.value = _state.value.copy(loading = false, lists = lists) return@launch } - _state.value = TaskEditUiState( - loading = false, - isNew = false, - title = task.title, - description = task.description.orEmpty(), - listId = task.listId, - start = task.start, - due = task.due, - isAllDay = task.isAllDay, - priority = task.priority, - parentId = task.parentId, - lists = lists, + _state.value = withFields( + TaskEditUiState( + loading = false, + isNew = false, + title = task.title, + description = task.description.orEmpty(), + listId = task.listId, + start = task.start, + due = task.due, + isAllDay = task.isAllDay, + priority = task.priority, + parentId = task.parentId, + lists = lists, + ), ) } } + /** Reveal an optional section (one tap from the "More fields" picker). */ + fun revealField(field: TaskFormField) = update { + val visible = it.visibleFields + field + it.copy(visibleFields = visible, hiddenFields = hiddenOf(visible)) + } + + /** + * Resolve which optional sections are visible: the default set, plus any + * field that already carries a value (so editing never hides real data). + */ + private fun withFields(state: TaskEditUiState): TaskEditUiState { + val visible = defaultFields + state.toForm().populatedFields() + return state.copy(visibleFields = visible, hiddenFields = hiddenOf(visible)) + } + + private fun hiddenOf(visible: Set): List = + TaskFormField.entries.filterNot { it in visible } + fun onTitleChange(value: String) = update { it.copy(title = value, errors = emptySet()) } fun onDescriptionChange(value: String) = update { it.copy(description = value) } fun onListChange(listId: Long) = update { it.copy(listId = listId, errors = emptySet()) } @@ -109,17 +143,7 @@ class TaskEditViewModel @Inject constructor( fun save() { val current = _state.value - val form = TaskForm( - title = current.title, - listId = current.listId ?: 0L, - description = current.description.ifBlank { null }, - start = current.start, - due = current.due, - isAllDay = current.isAllDay, - priority = current.priority, - parentId = current.parentId, - reminderMinutesBeforeDue = current.reminderMinutesBeforeDue, - ) + val form = current.toForm() val errors = form.validate() if (errors.isNotEmpty()) { _state.value = current.copy(errors = errors) @@ -142,3 +166,16 @@ class TaskEditViewModel @Inject constructor( _state.value = block(_state.value) } } + +/** Snapshot the editable state as a validated [TaskForm]. */ +private fun TaskEditUiState.toForm(): TaskForm = TaskForm( + title = title, + listId = listId ?: 0L, + description = description.ifBlank { null }, + start = start, + due = due, + isAllDay = isAllDay, + priority = priority, + parentId = parentId, + reminderMinutesBeforeDue = reminderMinutesBeforeDue, +) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cb1da08..8c8fd10 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -66,6 +66,11 @@ Reminder Set Clear + Task title + When + More fields + Close + Cancel None