android: a checkmark to leave, and asking for a checklist stops writing a blank one
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m23s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m10s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 7m16s

Two reports from the same device pass.

**The exit is a checkmark.** It shipped as the word "Done" one commit ago, on the
argument that a tick in a NOTES app reads as a checklist item to anyone who has
used one. Overruled by the operator, and the filled treatment is what settles the
objection anyway: a tonal button in the note's own colour is plainly a control,
where a bare glyph beside a checklist would not be. It carries "Done" as its
content description, so the argument survives where it actually mattered — read
aloud.

**Starting a checklist wrote an empty item**, purely so the section would have
something to render. That left a blank row with the always-present add-row beneath
it — two empty fields, and the caret in the lower one. Whether a checklist is
SHOWING is view state, not a row in the store: the toolbar reveals the section and
focuses the add row, and nothing reaches SQLite until an item has words in it.

EditorAction.AddChecklist is gone rather than repurposed (rule 22), which makes the
first real item the action that can create a body-less note — a note named from its
first item, which the core already does.
This commit is contained in:
2026-08-23 23:50:30 -04:00
parent 8257e1035c
commit 65af37d159
5 changed files with 94 additions and 40 deletions
@@ -263,11 +263,11 @@ class BoardViewModel(
} }
EditorAction.DismissError -> dismissError() EditorAction.DismissError -> dismissError()
is EditorAction.SaveText -> createFromDraft(action.body) is EditorAction.SaveText -> createFromDraft(action.body)
// Starting a checklist is the one toolbar action that means something on // The first checklist item is the one toolbar action that means something
// a note with no text: a note whose whole content is its items is a note // on a note with no text: a note whose whole content is its items is a
// this app already has (it is named from its first item). So it may // note this app already has, named from the first of them. So it may
// create an empty one — anything else needs words first. // create a body-less one — anything else needs words first.
EditorAction.AddChecklist -> is EditorAction.AddItem ->
createFromDraft(draft.body, allowEmpty = true) { created -> createFromDraft(draft.body, allowEmpty = true) { created ->
onEditorAction(created, action) onEditorAction(created, action)
} }
@@ -386,10 +386,6 @@ class BoardViewModel(
null null
} }
// An empty first item: the checklist editor appears the moment the note
// has one, and an empty row is what someone can type straight into.
EditorAction.AddChecklist -> mutate { it.addItem(id, "") }
is EditorAction.AddItem -> is EditorAction.AddItem ->
action.text.trim().takeIf { it.isNotEmpty() }?.let { text -> action.text.trim().takeIf { it.isNotEmpty() }?.let { text ->
mutate { it.addItem(id, text) } mutate { it.addItem(id, text) }
@@ -29,15 +29,6 @@ sealed interface EditorAction {
val color: String, val color: String,
) : EditorAction ) : EditorAction
/**
* Give this note a checklist.
*
* Not a conversion — a note HAS a checklist rather than BEING one (M13 step 2),
* so nothing moves and nothing is swapped: the body stays exactly where it is and
* the note gains a first, empty item for someone to type into.
*/
data object AddChecklist : EditorAction
data class SetPinned( data class SetPinned(
val pinned: Boolean, val pinned: Boolean,
) : EditorAction ) : EditorAction
@@ -52,6 +43,16 @@ sealed interface EditorAction {
data object DeleteForever : EditorAction data object DeleteForever : EditorAction
/**
* Add an item, given its text.
*
* There is deliberately no "add an EMPTY item" action. Starting a checklist used
* to be one, and it wrote a blank row to the store the moment the toolbar button
* was tapped — so the note grew an empty item that had to be typed into while the
* add-row sat below it, also empty and also asking to be typed into. Showing the
* checklist is now UI state, and the store hears nothing until there is an item
* with words in it.
*/
data class AddItem( data class AddItem(
val text: String, val text: String,
) : EditorAction ) : EditorAction
@@ -13,12 +13,15 @@ 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.runtime.Composable import androidx.compose.runtime.Composable
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.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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
@@ -45,6 +48,7 @@ import com.fabledsword.thoughtsync.core.Note
fun ChecklistEditor( fun ChecklistEditor(
note: Note, note: Note,
readOnly: Boolean, readOnly: Boolean,
focusAddRow: Boolean,
onAction: (EditorAction) -> Unit, onAction: (EditorAction) -> Unit,
) { ) {
Column { Column {
@@ -52,7 +56,10 @@ fun ChecklistEditor(
ChecklistRow(item = item, readOnly = readOnly, onAction = onAction) ChecklistRow(item = item, readOnly = readOnly, onAction = onAction)
} }
if (!readOnly) { if (!readOnly) {
AddItemRow(onAdd = { onAction(EditorAction.AddItem(it)) }) AddItemRow(
autoFocus = focusAddRow,
onAdd = { onAction(EditorAction.AddItem(it)) },
)
} }
} }
} }
@@ -116,10 +123,23 @@ private fun ChecklistRow(
* through — "milk ⏎ eggs ⏎ bread" — rather than costing a tap between each. That * through — "milk ⏎ eggs ⏎ bread" — rather than costing a tap between each. That
* is the same speed the capture sheet's one-item-per-line field buys, carried into * is the same speed the capture sheet's one-item-per-line field buys, carried into
* the editor so refining a list never feels slower than making one. * the editor so refining a list never feels slower than making one.
*
* [autoFocus] is set when the toolbar was just asked for a checklist, which is the
* one moment this row is certainly what someone is reaching for. Keyed on `Unit`
* rather than on the flag: the row only enters the composition when the checklist
* opens, so once is exactly right, and re-requesting later would yank the caret out
* of whatever was being typed.
*/ */
@Composable @Composable
private fun AddItemRow(onAdd: (String) -> Unit) { private fun AddItemRow(
autoFocus: Boolean,
onAdd: (String) -> Unit,
) {
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
val focus = remember { FocusRequester() }
LaunchedEffect(Unit) {
if (autoFocus) focus.requestFocus()
}
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Icon(
Icons.Filled.Add, Icons.Filled.Add,
@@ -130,7 +150,7 @@ private fun AddItemRow(onAdd: (String) -> Unit) {
PlainTextField( PlainTextField(
value = text, value = text,
onValueChange = { text = it }, onValueChange = { text = it },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f).focusRequester(focus),
hint = R.string.editor_add_item, hint = R.string.editor_add_item,
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
@@ -18,14 +18,17 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -76,7 +79,9 @@ fun EditorTopBar(
note: Note, note: Note,
readOnly: Boolean, readOnly: Boolean,
tint: NoteTint, tint: NoteTint,
showAddChecklist: Boolean,
onClose: () -> Unit, onClose: () -> Unit,
onStartChecklist: () -> Unit,
onPicker: (Picker) -> Unit, onPicker: (Picker) -> Unit,
onConfirmDelete: () -> Unit, onConfirmDelete: () -> Unit,
onAction: (EditorAction) -> Unit, onAction: (EditorAction) -> Unit,
@@ -115,12 +120,13 @@ fun EditorTopBar(
contentDescription = stringResource(R.string.editor_reminder), contentDescription = stringResource(R.string.editor_reminder),
) )
} }
// Adds the first checklist item, which is what makes the checklist // REVEALS the checklist; it does not write one. This used to add an
// editor appear. Hidden once the note already has one — there is // empty item so the section would have something to render, which
// nothing left to add that the checklist's own "+" row doesn't do // left a blank row above the add-row — two empty fields, and the
// better. // caret in the lower one. Hidden once the checklist is showing, since
if (note.items.isEmpty()) { // its own "+" row does the rest better.
IconButton(onClick = { onAction(EditorAction.AddChecklist) }) { if (showAddChecklist) {
IconButton(onClick = onStartChecklist) {
Icon( Icon(
Icons.AutoMirrored.Filled.List, Icons.AutoMirrored.Filled.List,
contentDescription = stringResource(R.string.editor_add_checklist), contentDescription = stringResource(R.string.editor_add_checklist),
@@ -179,9 +185,12 @@ fun EditorTopBar(
* back gesture and TalkBack all expect of a full-screen surface, and removing it * back gesture and TalkBack all expect of a full-screen surface, and removing it
* would strand the reflex to strike a duplicate that costs one icon slot. * would strand the reflex to strike a duplicate that costs one icon slot.
* *
* A WORD rather than a checkmark, like the overflow menu below and for the same * A checkmark, at the operator's ask. I had shipped the word "Done" here on the
* reason. A tick in a notes app is a checklist item to anyone who has used one, and * argument that a tick in a NOTES app reads as a checklist item; overruled, and the
* "Done" cannot be misread — including aloud. * filled treatment is what settles it — a tonal button in the note's own colour is
* plainly a control, where a bare glyph beside a checklist would not be. It carries
* "Done" as its content description, so the reasoning survives where it actually
* mattered: read aloud.
* *
* [DateUtils] rather than a hand-rolled formatter: it is localised, it already * [DateUtils] rather than a hand-rolled formatter: it is localised, it already
* knows the difference between minutes, hours and yesterday, and getting plurals * knows the difference between minutes, hours and yesterday, and getting plurals
@@ -191,9 +200,11 @@ fun EditorTopBar(
fun EditorFooter( fun EditorFooter(
updatedAt: String?, updatedAt: String?,
saving: Boolean, saving: Boolean,
tint: NoteTint,
onClose: () -> Unit, onClose: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val dark = isSystemInDarkTheme()
Row( Row(
modifier = modifier =
modifier modifier
@@ -204,9 +215,9 @@ fun EditorFooter(
// inset down. // inset down.
.imePadding() .imePadding()
.navigationBarsPadding() .navigationBarsPadding()
// Asymmetric: the button brings its own padding, the text does not. .padding(horizontal = 12.dp, vertical = 4.dp),
.padding(start = 16.dp, end = 4.dp), // The gap is what keeps the timestamp from reading as the button's label.
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
@@ -214,8 +225,17 @@ fun EditorFooter(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
TextButton(onClick = onClose) { FilledTonalIconButton(
Text(stringResource(R.string.editor_done)) onClick = onClose,
// The note's own colour rather than the scheme's secondaryContainer,
// which would be the one element on a tinted card ignoring the tint.
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = tint.chipBackground(dark),
contentColor = tint.chipForeground(dark),
),
) {
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.editor_done))
} }
} }
} }
@@ -91,6 +91,14 @@ fun NoteEditorScreen(
var picker by remember(sessionKey) { mutableStateOf(Picker.NONE) } var picker by remember(sessionKey) { mutableStateOf(Picker.NONE) }
var confirmingDelete by remember(sessionKey) { mutableStateOf(false) } var confirmingDelete by remember(sessionKey) { mutableStateOf(false) }
// Whether the checklist is SHOWING, which is not the same as whether the note has
// one. Asking for a checklist used to write an empty item, purely so the section
// would have something to render — which left a blank row with the add-row beneath
// it, both empty, and the caret in the wrong one. Nothing reaches the store now
// until an item has words in it. Saveable so a rotation does not close a list
// someone is halfway through typing.
var checklistOpen by rememberSaveable(sessionKey) { mutableStateOf(false) }
// A note in the trash is a record, not a document: editing one would silently // A note in the trash is a record, not a document: editing one would silently
// resurrect work that was meant to be thrown away. It renders read-only, with // resurrect work that was meant to be thrown away. It renders read-only, with
// Restore and Delete forever as the only things to do with it. // Restore and Delete forever as the only things to do with it.
@@ -177,7 +185,9 @@ fun NoteEditorScreen(
note = note, note = note,
readOnly = readOnly, readOnly = readOnly,
tint = tint, tint = tint,
showAddChecklist = !checklistOpen && note.items.isEmpty(),
onClose = leave, onClose = leave,
onStartChecklist = { checklistOpen = true },
onPicker = { picker = it }, onPicker = { picker = it },
onConfirmDelete = { confirmingDelete = true }, onConfirmDelete = { confirmingDelete = true },
onAction = onAction, onAction = onAction,
@@ -191,6 +201,7 @@ fun NoteEditorScreen(
EditorFooter( EditorFooter(
updatedAt = note.updatedAt, updatedAt = note.updatedAt,
saving = saving, saving = saving,
tint = tint,
onClose = leave, onClose = leave,
) )
}, },
@@ -231,11 +242,17 @@ fun NoteEditorScreen(
modifier = Modifier.focusRequester(bodyFocus), modifier = Modifier.focusRequester(bodyFocus),
) )
// Below the body, not instead of it, and only once the note has // Below the body, not instead of it. Shown once the note has
// items the toolbar's add-checklist action is what puts the // items, or once the toolbar has been asked for a checklist — in
// first one there. // which case the add row takes focus, because being asked for a
if (note.items.isNotEmpty()) { // list means being about to type one.
ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction) if (note.items.isNotEmpty() || checklistOpen) {
ChecklistEditor(
note = note,
readOnly = readOnly,
focusAddRow = checklistOpen && note.items.isEmpty(),
onAction = onAction,
)
} }
if (note.labels.isNotEmpty()) { if (note.labels.isNotEmpty()) {