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) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 11:03:02 +02:00
parent 3397e57794
commit 11f9649dd7
8 changed files with 898 additions and 194 deletions

View File

@@ -7,6 +7,8 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey 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.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
@@ -21,6 +23,8 @@ data class Settings(
val defaultListId: Long? = null, val defaultListId: Long? = null,
/** Default minutes before due to remind; 0 = at due time. */ /** Default minutes before due to remind; 0 = at due time. */
val reminderLeadMinutes: Int = 0, val reminderLeadMinutes: Int = 0,
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
val defaultEditFields: Set<TaskFormField> = emptySet(),
) )
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */ /** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
@@ -35,6 +39,9 @@ class SettingsPrefs @Inject constructor(
dynamicColor = p[DYNAMIC_COLOR] ?: true, dynamicColor = p[DYNAMIC_COLOR] ?: true,
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 }, defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
reminderLeadMinutes = p[REMINDER_LEAD] ?: 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 setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
}
private companion object { private companion object {
val THEME_MODE = stringPreferencesKey("theme_mode") val THEME_MODE = stringPreferencesKey("theme_mode")
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color") val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
val DEFAULT_LIST_ID = longPreferencesKey("default_list_id") val DEFAULT_LIST_ID = longPreferencesKey("default_list_id")
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes") val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
} }
} }

View File

@@ -30,3 +30,18 @@ data class TaskForm(
} }
enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE } 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<TaskFormField> = buildSet {
if (!description.isNullOrBlank()) add(TaskFormField.Description)
if (priority != Priority.NONE) add(TaskFormField.Priority)
if (reminderMinutesBeforeDue != null) add(TaskFormField.Reminder)
}

View File

@@ -40,13 +40,13 @@ import kotlin.time.Instant
private val zone: ZoneId get() = ZoneId.systemDefault() 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() 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() 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()) Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli())
/** /**

View File

@@ -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,
)
}

View File

@@ -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
},
)
}
}
}
}
}

View File

@@ -1,55 +1,102 @@
package de.jeanlucmakiola.floret.ui.edit 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding 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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.Notes
import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Add
import androidx.compose.material3.DropdownMenuItem 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.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.TopAppBar 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
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.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.floret.R import de.jeanlucmakiola.floret.R
import de.jeanlucmakiola.floret.domain.Priority import de.jeanlucmakiola.floret.domain.Priority
import de.jeanlucmakiola.floret.domain.TaskFormError 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 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 * Create / edit form, in the family's tonal-card language (mirrors Calendula's
* all-day toggle, priority and a reminder offset — validated through the M1 * event editor): a borderless headline title with a list-colour accent bar, a
* [TaskEditViewModel] / [de.jeanlucmakiola.floret.domain.TaskForm]. The VM flips * "When" card with the all-day toggle and tappable schedule rows, a tappable
* `saved` once the write lands, which pops us back. * 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -67,158 +114,605 @@ fun TaskEditScreen(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {},
Text(
stringResource(
if (state.isNew) R.string.new_task_title else R.string.edit_task_title,
),
)
},
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.close))
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.back),
)
} }
}, },
actions = { actions = {
IconButton(onClick = viewModel::save, enabled = !state.loading) { Button(
Icon(Icons.Rounded.Check, contentDescription = stringResource(R.string.save)) 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 -> ) { inner ->
if (state.loading) return@Scaffold if (state.loading) return@Scaffold
EditContent(
state = state,
viewModel = viewModel,
modifier = Modifier.padding(inner),
)
}
}
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<PickerTarget?>(null) }
var showListPicker by rememberSaveable { mutableStateOf(false) }
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
var showFieldPicker by rememberSaveable { mutableStateOf(false) }
Column( Column(
modifier = Modifier modifier = modifier
.padding(inner) // Shrink the scroll viewport by the keyboard so the focused field
// Shrink the scroll viewport by the keyboard so the focused // scrolls into view above the IME instead of being hidden.
// field scrolls into view above the IME instead of being hidden.
.imePadding() .imePadding()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
OutlinedTextField( // Title: borderless headline + accent bar, mirroring the detail screen.
InlineField(
value = state.title, value = state.title,
onValueChange = viewModel::onTitleChange, onValueChange = viewModel::onTitleChange,
label = { Text(stringResource(R.string.field_title)) }, placeholder = stringResource(R.string.edit_title_hint),
singleLine = true, textStyle = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold),
isError = TaskFormError.BLANK_TITLE in state.errors,
supportingText = errorText(TaskFormError.BLANK_TITLE in state.errors, R.string.error_blank_title),
modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(10.dp))
OutlinedTextField( Box(
value = state.description, modifier = Modifier
onValueChange = viewModel::onDescriptionChange, .width(48.dp)
label = { Text(stringResource(R.string.field_description)) }, .height(3.dp)
modifier = Modifier.fillMaxWidth(), .background(accent, RoundedCornerShape(2.dp)),
) )
if (TaskFormError.BLANK_TITLE in state.errors) {
ListPicker( Spacer(Modifier.height(6.dp))
lists = state.lists, FieldError(R.string.error_blank_title)
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(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)
}
},
) {
Spacer(Modifier.height(4.dp))
ScheduleRow(
label = stringResource(R.string.edit_start_label), label = stringResource(R.string.edit_start_label),
value = state.start, value = state.start,
allDay = state.isAllDay, allDay = state.isAllDay,
onChange = viewModel::onStartChange, onPick = { pickerTarget = PickerTarget.Start },
onClear = { viewModel.onStartChange(null) },
) )
DateTimeField( ScheduleRow(
label = stringResource(R.string.edit_due_label), label = stringResource(R.string.edit_due_label),
value = state.due, value = state.due,
allDay = state.isAllDay, 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) { if (TaskFormError.DUE_BEFORE_START in state.errors) {
Spacer(Modifier.height(2.dp))
FieldError(R.string.error_due_before_start) FieldError(R.string.error_due_before_start)
} }
}
// Priority Spacer(Modifier.height(gap))
Column {
// 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(
stringResource(R.string.edit_priority_label), text = selectedList?.name ?: stringResource(R.string.error_no_list),
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 4.dp, bottom = 6.dp), color = if (selectedList == null) MaterialTheme.colorScheme.error else Color.Unspecified,
) )
selectedList?.accountName?.takeIf { it.isNotBlank() }?.let { account ->
Text(
text = account,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// 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()) { SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
Priority.entries.forEachIndexed { index, priority -> Priority.entries.forEachIndexed { index, priority ->
SegmentedButton( SegmentedButton(
selected = state.priority == priority, selected = state.priority == priority,
onClick = { viewModel.onPriorityChange(priority) }, onClick = { viewModel.onPriorityChange(priority) },
shape = SegmentedButtonDefaults.itemShape(index, Priority.entries.size), shape = SegmentedButtonDefaults.itemShape(index, Priority.entries.size),
) { Text(priorityLabel(priority)) } // 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( OptionalFormSection(visible = TaskFormField.Reminder in state.visibleFields) {
minutes = state.reminderMinutesBeforeDue, Spacer(Modifier.height(gap))
onSelect = viewModel::onReminderChange, 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) { if (TaskFormError.REMINDER_WITHOUT_DUE in state.errors) {
Spacer(Modifier.height(2.dp))
FieldError(R.string.error_reminder_without_due) 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ListPicker( private fun DateTimePickerFlow(
lists: List<de.jeanlucmakiola.floret.domain.TaskList>, initial: Instant?,
selectedId: Long?, allDay: Boolean,
isError: Boolean, onResult: (Instant) -> Unit,
onSelect: (Long) -> Unit, onDismiss: () -> Unit,
) { ) {
var expanded by remember { mutableStateOf(false) } var pendingDate by remember { mutableStateOf<java.time.LocalDate?>(null) }
val selectedName = lists.firstOrNull { it.id == selectedId }?.name.orEmpty() var showTime by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) { if (!showTime) {
OutlinedTextField( val initialMillis = (initial ?: nowInstant())
value = selectedName, .toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
onValueChange = {}, val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
readOnly = true, DatePickerDialog(
label = { Text(stringResource(R.string.edit_list_label)) }, onDismissRequest = onDismiss,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, confirmButton = {
isError = isError, TextButton(onClick = {
supportingText = errorText(isError, R.string.error_no_list), val millis = dateState.selectedDateMillis ?: run { onDismiss(); return@TextButton }
modifier = Modifier val date = java.time.Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
.fillMaxWidth() if (allDay) {
.menuAnchor(MenuAnchorType.PrimaryNotEditable), 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,
) )
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { AlertDialog(
lists.forEach { list -> onDismissRequest = onDismiss,
DropdownMenuItem( text = { TimePicker(state = timeState) },
text = { Text(list.name) }, confirmButton = {
onClick = { TextButton(onClick = {
onSelect(list.id) val date = pendingDate ?: return@TextButton
expanded = false 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)) }
}, },
) )
} }
}
@Composable
private fun ListPickerDialog(
lists: List<TaskList>,
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<TaskFormField>,
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 data class ReminderOption(val labelRes: Int, val minutes: Int?)
@@ -233,52 +727,15 @@ private val reminderOptions = listOf(
ReminderOption(R.string.reminder_1_day, 1_440), ReminderOption(R.string.reminder_1_day, 1_440),
) )
@OptIn(ExperimentalMaterial3Api::class) private fun reminderOptionFor(minutes: Int?): ReminderOption =
@Composable reminderOptions.firstOrNull { it.minutes == minutes } ?: reminderOptions.first()
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
}
@Composable @Composable
private fun FieldError(messageRes: Int) { private fun FieldError(messageRes: Int) {
Text( Text(
text = stringResource(messageRes), text = stringResource(messageRes),
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 4.dp), modifier = Modifier.padding(start = 4.dp),
) )
} }

View File

@@ -9,7 +9,9 @@ import de.jeanlucmakiola.floret.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.Priority import de.jeanlucmakiola.floret.domain.Priority
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.floret.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskFormError import de.jeanlucmakiola.floret.domain.TaskFormError
import de.jeanlucmakiola.floret.domain.TaskFormField
import de.jeanlucmakiola.floret.domain.TaskList import de.jeanlucmakiola.floret.domain.TaskList
import de.jeanlucmakiola.floret.domain.populatedFields
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -36,6 +38,10 @@ data class TaskEditUiState(
val parentId: Long? = null, val parentId: Long? = null,
val reminderMinutesBeforeDue: Int? = null, val reminderMinutesBeforeDue: Int? = null,
val lists: List<TaskList> = emptyList(), val lists: List<TaskList> = emptyList(),
/** Optional sections currently shown (core Title / List / When are always on). */
val visibleFields: Set<TaskFormField> = emptySet(),
/** Optional sections still tucked behind "More fields". */
val hiddenFields: List<TaskFormField> = emptyList(),
val errors: Set<TaskFormError> = emptySet(), val errors: Set<TaskFormError> = emptySet(),
val saveFailed: Boolean = false, val saveFailed: Boolean = false,
val saved: Boolean = false, val saved: Boolean = false,
@@ -53,21 +59,28 @@ class TaskEditViewModel @Inject constructor(
private var editingTaskId: Long? = null private var editingTaskId: Long? = null
/** Sections shown by default (a setting); the rest unfold via [revealField]. */
private var defaultFields: Set<TaskFormField> = emptySet()
/** Start a fresh task, optionally pre-selecting a list / parent. */ /** Start a fresh task, optionally pre-selecting a list / parent. */
fun bindNew(presetListId: Long? = null, parentId: Long? = null) { fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
editingTaskId = null editingTaskId = null
viewModelScope.launch { viewModelScope.launch {
val settings = settingsPrefs.settings.first()
defaultFields = settings.defaultEditFields
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() } val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
val defaultList = presetListId val defaultList = presetListId
?: settingsPrefs.settings.first().defaultListId ?: settings.defaultListId
?: lists.firstOrNull { !it.isLocal }?.id ?: lists.firstOrNull { !it.isLocal }?.id
?: lists.firstOrNull()?.id ?: lists.firstOrNull()?.id
_state.value = TaskEditUiState( _state.value = withFields(
TaskEditUiState(
loading = false, loading = false,
isNew = true, isNew = true,
listId = defaultList, listId = defaultList,
parentId = parentId, parentId = parentId,
lists = lists, lists = lists,
),
) )
} }
} }
@@ -76,13 +89,15 @@ class TaskEditViewModel @Inject constructor(
fun bindEdit(taskId: Long) { fun bindEdit(taskId: Long) {
editingTaskId = taskId editingTaskId = taskId
viewModelScope.launch { viewModelScope.launch {
defaultFields = settingsPrefs.settings.first().defaultEditFields
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() } val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
val task = runCatching { repository.taskDetail(taskId).first()?.task }.getOrNull() val task = runCatching { repository.taskDetail(taskId).first()?.task }.getOrNull()
if (task == null) { if (task == null) {
_state.value = _state.value.copy(loading = false, lists = lists) _state.value = _state.value.copy(loading = false, lists = lists)
return@launch return@launch
} }
_state.value = TaskEditUiState( _state.value = withFields(
TaskEditUiState(
loading = false, loading = false,
isNew = false, isNew = false,
title = task.title, title = task.title,
@@ -94,10 +109,29 @@ class TaskEditViewModel @Inject constructor(
priority = task.priority, priority = task.priority,
parentId = task.parentId, parentId = task.parentId,
lists = lists, 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<TaskFormField>): List<TaskFormField> =
TaskFormField.entries.filterNot { it in visible }
fun onTitleChange(value: String) = update { it.copy(title = value, errors = emptySet()) } fun onTitleChange(value: String) = update { it.copy(title = value, errors = emptySet()) }
fun onDescriptionChange(value: String) = update { it.copy(description = value) } fun onDescriptionChange(value: String) = update { it.copy(description = value) }
fun onListChange(listId: Long) = update { it.copy(listId = listId, errors = emptySet()) } fun onListChange(listId: Long) = update { it.copy(listId = listId, errors = emptySet()) }
@@ -109,17 +143,7 @@ class TaskEditViewModel @Inject constructor(
fun save() { fun save() {
val current = _state.value val current = _state.value
val form = TaskForm( val form = current.toForm()
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 errors = form.validate() val errors = form.validate()
if (errors.isNotEmpty()) { if (errors.isNotEmpty()) {
_state.value = current.copy(errors = errors) _state.value = current.copy(errors = errors)
@@ -142,3 +166,16 @@ class TaskEditViewModel @Inject constructor(
_state.value = block(_state.value) _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,
)

View File

@@ -66,6 +66,11 @@
<string name="edit_reminder_label">Reminder</string> <string name="edit_reminder_label">Reminder</string>
<string name="edit_set">Set</string> <string name="edit_set">Set</string>
<string name="edit_clear">Clear</string> <string name="edit_clear">Clear</string>
<string name="edit_title_hint">Task title</string>
<string name="edit_when">When</string>
<string name="edit_more_fields">More fields</string>
<string name="close">Close</string>
<string name="dialog_cancel">Cancel</string>
<!-- Reminder offsets --> <!-- Reminder offsets -->
<string name="reminder_none">None</string> <string name="reminder_none">None</string>