android: a checkmark to leave, and asking for a checklist stops writing a blank one
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:
@@ -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()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user