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.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<TaskFormField> = 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<TaskFormField>) = 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")
}
}

View File

@@ -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<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 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())
/**

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
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<PickerTarget?>(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<de.jeanlucmakiola.floret.domain.TaskList>,
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<java.time.LocalDate?>(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<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 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),
)
}

View File

@@ -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<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 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<TaskFormField> = 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<TaskFormField>): List<TaskFormField> =
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,
)

View File

@@ -66,6 +66,11 @@
<string name="edit_reminder_label">Reminder</string>
<string name="edit_set">Set</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 -->
<string name="reminder_none">None</string>