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:
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user