Checklists in the body, colour from tags, and commit-derived CalVer #4
@@ -218,6 +218,7 @@ private fun App(
|
|||||||
onOpenSync = { showingSync = true },
|
onOpenSync = { showingSync = true },
|
||||||
onSearch = board::search,
|
onSearch = board::search,
|
||||||
onCompose = board::compose,
|
onCompose = board::compose,
|
||||||
|
onToggleItem = board::toggleItem,
|
||||||
onDismissError = board::dismissError,
|
onDismissError = board::dismissError,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ fun BoardScreen(
|
|||||||
onOpenSync: () -> Unit,
|
onOpenSync: () -> Unit,
|
||||||
onSearch: (String) -> Unit,
|
onSearch: (String) -> Unit,
|
||||||
onCompose: () -> Unit,
|
onCompose: () -> Unit,
|
||||||
|
onToggleItem: (Note, Int, Boolean) -> Unit,
|
||||||
onDismissError: () -> Unit,
|
onDismissError: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -336,7 +337,11 @@ private fun NoteBoard(
|
|||||||
// rebuilding them — and so a newly captured note slides in instead of
|
// rebuilding them — and so a newly captured note slides in instead of
|
||||||
// making every card below it flicker.
|
// making every card below it flicker.
|
||||||
items(items = notes, key = { it.id }) { note ->
|
items(items = notes, key = { it.id }) { note ->
|
||||||
NoteCard(note = note, onOpen = { onOpenNote(note) })
|
NoteCard(
|
||||||
|
note = note,
|
||||||
|
onOpen = { onOpenNote(note) },
|
||||||
|
onToggleItem = { index, checked -> onToggleItem(note, index, checked) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,14 +263,6 @@ class BoardViewModel(
|
|||||||
}
|
}
|
||||||
EditorAction.DismissError -> dismissError()
|
EditorAction.DismissError -> dismissError()
|
||||||
is EditorAction.SaveText -> createFromDraft(action.body)
|
is EditorAction.SaveText -> createFromDraft(action.body)
|
||||||
// The first checklist item is the one toolbar action that means something
|
|
||||||
// on a note with no text: a note whose whole content is its items is a
|
|
||||||
// note this app already has, named from the first of them. So it may
|
|
||||||
// create a body-less one — anything else needs words first.
|
|
||||||
is EditorAction.AddItem ->
|
|
||||||
createFromDraft(draft.body, allowEmpty = true) { created ->
|
|
||||||
onEditorAction(created, action)
|
|
||||||
}
|
|
||||||
// Colour, reminder, pin, labels: attributes OF a note, so there has to be
|
// Colour, reminder, pin, labels: attributes OF a note, so there has to be
|
||||||
// a note. With autosave at a second, "typed something" is true by the time
|
// a note. With autosave at a second, "typed something" is true by the time
|
||||||
// anyone reaches the toolbar; before that there is nothing to attribute.
|
// anyone reaches the toolbar; before that there is nothing to attribute.
|
||||||
@@ -386,16 +378,6 @@ class BoardViewModel(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
is EditorAction.AddItem ->
|
|
||||||
action.text.trim().takeIf { it.isNotEmpty() }?.let { text ->
|
|
||||||
mutate { it.addItem(id, text) }
|
|
||||||
}
|
|
||||||
is EditorAction.SetItemChecked ->
|
|
||||||
mutate { it.setItemChecked(id, action.itemId, action.checked) }
|
|
||||||
is EditorAction.SetItemText ->
|
|
||||||
mutate { it.setItemText(id, action.itemId, action.text) }
|
|
||||||
is EditorAction.DeleteItem -> mutate { it.deleteItem(id, action.itemId) }
|
|
||||||
|
|
||||||
is EditorAction.SetLabels -> mutate { it.setNoteLabels(id, action.labelIds) }
|
is EditorAction.SetLabels -> mutate { it.setNoteLabels(id, action.labelIds) }
|
||||||
|
|
||||||
is EditorAction.CreateLabel ->
|
is EditorAction.CreateLabel ->
|
||||||
@@ -482,6 +464,21 @@ class BoardViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tick or untick one item from the BOARD, without opening the note.
|
||||||
|
*
|
||||||
|
* The common gesture on a checklist, and the reason it goes through the store
|
||||||
|
* rather than the pure text helpers the editor uses: nothing here is holding a
|
||||||
|
* half-typed body, so the reloaded note is simply the truth.
|
||||||
|
*
|
||||||
|
* `index` is the item's ordinal, which is what its id is now (M304).
|
||||||
|
*/
|
||||||
|
fun toggleItem(
|
||||||
|
note: Note,
|
||||||
|
index: Int,
|
||||||
|
checked: Boolean,
|
||||||
|
) = mutate { it.setItemChecked(note.id, index.toString(), checked) }
|
||||||
|
|
||||||
fun dismissError() {
|
fun dismissError() {
|
||||||
state = state.copy(error = null)
|
state = state.copy(error = null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,33 +43,12 @@ sealed interface EditorAction {
|
|||||||
|
|
||||||
data object DeleteForever : EditorAction
|
data object DeleteForever : EditorAction
|
||||||
|
|
||||||
/**
|
// No checklist actions at all any more (M304). An item is a `- [ ] ` line of the
|
||||||
* Add an item, given its text.
|
// body, so adding, renaming, ticking or deleting one is editing text — which the
|
||||||
*
|
// editor already does, through SaveText, with the same autosave and the same
|
||||||
* There is deliberately no "add an EMPTY item" action. Starting a checklist used
|
// revision window as any other edit. Routing them through the store would have
|
||||||
* to be one, and it wrote a blank row to the store the moment the toolbar button
|
// meant the store handing back a note whose body disagreed with the field the
|
||||||
* was tapped — so the note grew an empty item that had to be typed into while the
|
// person was typing in.
|
||||||
* 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(
|
|
||||||
val text: String,
|
|
||||||
) : EditorAction
|
|
||||||
|
|
||||||
data class SetItemChecked(
|
|
||||||
val itemId: String,
|
|
||||||
val checked: Boolean,
|
|
||||||
) : EditorAction
|
|
||||||
|
|
||||||
data class SetItemText(
|
|
||||||
val itemId: String,
|
|
||||||
val text: String,
|
|
||||||
) : EditorAction
|
|
||||||
|
|
||||||
data class DeleteItem(
|
|
||||||
val itemId: String,
|
|
||||||
) : EditorAction
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The note's MANUAL labels, replacing whatever was there.
|
* The note's MANUAL labels, replacing whatever was there.
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
package com.fabledsword.thoughtsync.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Add
|
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material3.Checkbox
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
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.res.stringResource
|
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.fabledsword.thoughtsync.R
|
|
||||||
import com.fabledsword.thoughtsync.core.ChecklistItem
|
|
||||||
import com.fabledsword.thoughtsync.core.Note
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The checklist, with real checkboxes this time.
|
|
||||||
*
|
|
||||||
* The card renders glyphs because it is a preview; here every row is live. This is
|
|
||||||
* the other half of the answer to how a list gets typed on a phone: the capture
|
|
||||||
* sheet takes a whole list at once, one item per line, because at capture time the
|
|
||||||
* list is already in your head and a tap per row would be the slow part. The
|
|
||||||
* editor is where a list is REVISED, and revising is item-at-a-time — so this is
|
|
||||||
* where the per-row control lives.
|
|
||||||
*
|
|
||||||
* No empty state: a checklist with no items already shows the add row with its
|
|
||||||
* hint, which says the same thing an empty state would and can be typed into.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ChecklistEditor(
|
|
||||||
note: Note,
|
|
||||||
readOnly: Boolean,
|
|
||||||
focusAddRow: Boolean,
|
|
||||||
onAction: (EditorAction) -> Unit,
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
note.items.forEach { item ->
|
|
||||||
ChecklistRow(item = item, readOnly = readOnly, onAction = onAction)
|
|
||||||
}
|
|
||||||
if (!readOnly) {
|
|
||||||
AddItemRow(
|
|
||||||
autoFocus = focusAddRow,
|
|
||||||
onAdd = { onAction(EditorAction.AddItem(it)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One row: a live checkbox, editable text, and a remove button.
|
|
||||||
*
|
|
||||||
* The text commits on FOCUS LOSS rather than per keystroke. Every commit is a
|
|
||||||
* store write that reloads the note, so per-keystroke saving would both hammer
|
|
||||||
* SQLite and race the reload against the next character.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun ChecklistRow(
|
|
||||||
item: ChecklistItem,
|
|
||||||
readOnly: Boolean,
|
|
||||||
onAction: (EditorAction) -> Unit,
|
|
||||||
) {
|
|
||||||
// Keyed by item id, so a reload after some OTHER row's edit doesn't reset the
|
|
||||||
// text being typed here.
|
|
||||||
var text by remember(item.id) { mutableStateOf(item.text) }
|
|
||||||
val commit = { if (text != item.text) onAction(EditorAction.SetItemText(item.id, text)) }
|
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Checkbox(
|
|
||||||
checked = item.checked,
|
|
||||||
onCheckedChange = { onAction(EditorAction.SetItemChecked(item.id, it)) },
|
|
||||||
enabled = !readOnly,
|
|
||||||
)
|
|
||||||
PlainTextField(
|
|
||||||
value = text,
|
|
||||||
onValueChange = { text = it },
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.onFocusChanged { if (!it.isFocused) commit() },
|
|
||||||
enabled = !readOnly,
|
|
||||||
singleLine = true,
|
|
||||||
textStyle =
|
|
||||||
MaterialTheme.typography.bodyLarge.copy(
|
|
||||||
// Struck through when done, matching the card and the web.
|
|
||||||
textDecoration = if (item.checked) TextDecoration.LineThrough else null,
|
|
||||||
),
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
||||||
keyboardActions = KeyboardActions(onDone = { commit() }),
|
|
||||||
)
|
|
||||||
if (!readOnly) {
|
|
||||||
IconButton(onClick = { onAction(EditorAction.DeleteItem(item.id)) }) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Close,
|
|
||||||
contentDescription = stringResource(R.string.editor_remove_item),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The always-present row at the bottom for adding an item.
|
|
||||||
*
|
|
||||||
* It clears but keeps focus after a submit, so a list can be typed straight
|
|
||||||
* 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
|
|
||||||
* 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
|
|
||||||
private fun AddItemRow(
|
|
||||||
autoFocus: Boolean,
|
|
||||||
onAdd: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
var text by remember { mutableStateOf("") }
|
|
||||||
val focus = remember { FocusRequester() }
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
if (autoFocus) focus.requestFocus()
|
|
||||||
}
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Add,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
PlainTextField(
|
|
||||||
value = text,
|
|
||||||
onValueChange = { text = it },
|
|
||||||
modifier = Modifier.weight(1f).focusRequester(focus),
|
|
||||||
hint = R.string.editor_add_item,
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
|
||||||
keyboardActions =
|
|
||||||
KeyboardActions(onDone = {
|
|
||||||
onAdd(text)
|
|
||||||
text = ""
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -79,7 +79,6 @@ fun EditorTopBar(
|
|||||||
note: Note,
|
note: Note,
|
||||||
readOnly: Boolean,
|
readOnly: Boolean,
|
||||||
tint: NoteTint,
|
tint: NoteTint,
|
||||||
showAddChecklist: Boolean,
|
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onStartChecklist: () -> Unit,
|
onStartChecklist: () -> Unit,
|
||||||
onPicker: (Picker) -> Unit,
|
onPicker: (Picker) -> Unit,
|
||||||
@@ -120,12 +119,10 @@ fun EditorTopBar(
|
|||||||
contentDescription = stringResource(R.string.editor_reminder),
|
contentDescription = stringResource(R.string.editor_reminder),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// REVEALS the checklist; it does not write one. This used to add an
|
// Inserts `- [ ] ` at the caret. Always available, and never hidden:
|
||||||
// empty item so the section would have something to render, which
|
// a checklist is text now (M304), so there is no section to be
|
||||||
// left a blank row above the add-row — two empty fields, and the
|
// already-showing and no reason a second list cannot start further
|
||||||
// caret in the lower one. Hidden once the checklist is showing, since
|
// down the same note.
|
||||||
// its own "+" row does the rest better.
|
|
||||||
if (showAddChecklist) {
|
|
||||||
IconButton(onClick = onStartChecklist) {
|
IconButton(onClick = onStartChecklist) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.AutoMirrored.Filled.List,
|
Icons.AutoMirrored.Filled.List,
|
||||||
@@ -133,7 +130,6 @@ fun EditorTopBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
OverflowMenu(
|
OverflowMenu(
|
||||||
note = note,
|
note = note,
|
||||||
readOnly = readOnly,
|
readOnly = readOnly,
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
@@ -27,12 +27,14 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.fabledsword.thoughtsync.R
|
import com.fabledsword.thoughtsync.R
|
||||||
import com.fabledsword.thoughtsync.core.ChecklistItem
|
import com.fabledsword.thoughtsync.core.ChecklistItem
|
||||||
import com.fabledsword.thoughtsync.core.Note
|
import com.fabledsword.thoughtsync.core.Note
|
||||||
|
import com.fabledsword.thoughtsync.core.checklistLines
|
||||||
import com.fabledsword.thoughtsync.core.NoteLabel
|
import com.fabledsword.thoughtsync.core.NoteLabel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NoteCard(
|
fun NoteCard(
|
||||||
note: Note,
|
note: Note,
|
||||||
onOpen: () -> Unit,
|
onOpen: () -> Unit,
|
||||||
|
onToggleItem: (Int, Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val tint = noteTint(note.color)
|
val tint = noteTint(note.color)
|
||||||
@@ -53,21 +55,12 @@ fun NoteCard(
|
|||||||
// nothing above them: the first line of the body IS the note's name, at the
|
// nothing above them: the first line of the body IS the note's name, at the
|
||||||
// same weight as the rest of it (M13 steps 3 and 4).
|
// same weight as the rest of it (M13 steps 3 and 4).
|
||||||
if (note.body.isNotBlank()) {
|
if (note.body.isNotBlank()) {
|
||||||
Text(
|
NoteBody(note = note, onToggleItem = onToggleItem)
|
||||||
text = note.body,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
maxLines = MAX_PREVIEW_LINES,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (note.items.isNotEmpty()) {
|
|
||||||
if (note.body.isNotBlank()) Spacer(Modifier.height(4.dp))
|
|
||||||
Checklist(items = note.items)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A note with no body and no items still has to occupy the board legibly —
|
// A note with nothing in it still has to occupy the board legibly — otherwise
|
||||||
// otherwise it reads as a rendering bug.
|
// it reads as a rendering bug.
|
||||||
if (note.body.isBlank() && note.items.isEmpty()) {
|
if (note.body.isBlank()) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.board_empty_note),
|
text = stringResource(R.string.board_empty_note),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@@ -88,18 +81,79 @@ fun NoteCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The note's body, with its checklist drawn where it actually sits.
|
||||||
|
*
|
||||||
|
* Rendered line by line rather than as one block of text, because an item is a line
|
||||||
|
* of the body now (M304) and a card that showed the prose and then the list would put
|
||||||
|
* every list in the wrong place — and, since the body already contains those lines,
|
||||||
|
* would show each one twice.
|
||||||
|
*
|
||||||
|
* Which lines are items is asked of the core rather than matched here. The grammar is
|
||||||
|
* already written three times; a fourth in Compose would be a fourth place for a
|
||||||
|
* checklist to change shape when it syncs.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun Checklist(items: List<ChecklistItem>) {
|
private fun NoteBody(
|
||||||
|
note: Note,
|
||||||
|
onToggleItem: (Int, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val lines = remember(note.body) { note.body.split("\n") }
|
||||||
|
val itemAtLine =
|
||||||
|
remember(note.body) {
|
||||||
|
checklistLines(note.body).withIndex().associate { (i, line) -> line.toInt() to i }
|
||||||
|
}
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
items.take(MAX_CHECKLIST_ROWS).forEach { item ->
|
lines.take(MAX_PREVIEW_LINES).forEachIndexed { n, line ->
|
||||||
|
val index = itemAtLine[n]
|
||||||
|
val item = index?.let { note.items.getOrNull(it) }
|
||||||
|
when {
|
||||||
|
item != null -> ChecklistRow(item) { onToggleItem(index, !item.checked) }
|
||||||
|
// Kept as a gap rather than dropped: it is the paragraph break
|
||||||
|
// somebody typed, and the card reads as a wall without it.
|
||||||
|
line.isBlank() -> Spacer(Modifier.height(4.dp))
|
||||||
|
else ->
|
||||||
|
Text(
|
||||||
|
text = line,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = MAX_WRAPPED_LINES,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lines.size > MAX_PREVIEW_LINES) {
|
||||||
|
Text(
|
||||||
|
text = "…",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One checklist row on a card, with a box you can actually tick.
|
||||||
|
*
|
||||||
|
* A glyph rather than a Material Checkbox: it sits on a line of text and has to share
|
||||||
|
* that line's metrics, and a real Checkbox brings 48dp of touch target that would
|
||||||
|
* space a list out like a form. The tap target is the glyph's own padding, which is
|
||||||
|
* why it carries `clickable` rather than the row — clicking the TEXT should open the
|
||||||
|
* note, the way clicking anywhere else on the card does.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ChecklistRow(
|
||||||
|
item: ChecklistItem,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
) {
|
||||||
Row(verticalAlignment = Alignment.Top) {
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
// A glyph rather than a real Checkbox: the card is a PREVIEW, and
|
|
||||||
// a live control here would invite taps that the board cannot yet
|
|
||||||
// honour. It becomes interactive with the editor.
|
|
||||||
Text(
|
Text(
|
||||||
text = if (item.checked) "☑" else "☐",
|
text = if (item.checked) "☑" else "☐",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.padding(end = 6.dp),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.clickable(onClick = onToggle)
|
||||||
|
.padding(end = 6.dp),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = item.text,
|
text = item.text,
|
||||||
@@ -111,22 +165,11 @@ private fun Checklist(items: List<ChecklistItem>) {
|
|||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
},
|
},
|
||||||
maxLines = 2,
|
maxLines = MAX_WRAPPED_LINES,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val hidden = items.size - MAX_CHECKLIST_ROWS
|
|
||||||
if (hidden > 0) {
|
|
||||||
Text(
|
|
||||||
text = pluralStringResource(R.plurals.board_more_items, hidden, hidden),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 2.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LabelChips(labels: List<NoteLabel>) {
|
private fun LabelChips(labels: List<NoteLabel>) {
|
||||||
@@ -182,7 +225,9 @@ private fun ReminderChip(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const val MAX_PREVIEW_LINES = 8
|
private const val MAX_PREVIEW_LINES = 8
|
||||||
private const val MAX_CHECKLIST_ROWS = 8
|
|
||||||
|
/** How far one long line of a card may wrap before it is cut. */
|
||||||
|
private const val MAX_WRAPPED_LINES = 2
|
||||||
private const val MAX_LABEL_CHIPS = 3
|
private const val MAX_LABEL_CHIPS = 3
|
||||||
private val CARD_RADIUS = 12.dp
|
private val CARD_RADIUS = 12.dp
|
||||||
private val CHIP_RADIUS = 6.dp
|
private val CHIP_RADIUS = 6.dp
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.fabledsword.thoughtsync.R
|
import com.fabledsword.thoughtsync.R
|
||||||
import com.fabledsword.thoughtsync.core.Label
|
import com.fabledsword.thoughtsync.core.Label
|
||||||
import com.fabledsword.thoughtsync.core.Note
|
import com.fabledsword.thoughtsync.core.Note
|
||||||
|
import com.fabledsword.thoughtsync.core.checklistContinuation
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,13 +92,6 @@ 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
|
||||||
@@ -185,9 +179,8 @@ 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 },
|
onStartChecklist = { body = insertChecklistMarker(body) },
|
||||||
onPicker = { picker = it },
|
onPicker = { picker = it },
|
||||||
onConfirmDelete = { confirmingDelete = true },
|
onConfirmDelete = { confirmingDelete = true },
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
@@ -235,25 +228,16 @@ fun NoteEditorScreen(
|
|||||||
// render bolder than the line beneath it (M13 steps 3 and 4).
|
// render bolder than the line beneath it (M13 steps 3 and 4).
|
||||||
EditorField(
|
EditorField(
|
||||||
value = body,
|
value = body,
|
||||||
onValueChange = { body = it },
|
onValueChange = { body = continueChecklist(body, it) },
|
||||||
hint = R.string.editor_body_hint,
|
hint = R.string.editor_body_hint,
|
||||||
enabled = !readOnly,
|
enabled = !readOnly,
|
||||||
minLines = MIN_BODY_LINES,
|
minLines = MIN_BODY_LINES,
|
||||||
modifier = Modifier.focusRequester(bodyFocus),
|
modifier = Modifier.focusRequester(bodyFocus),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Below the body, not instead of it. Shown once the note has
|
// No checklist section. The items ARE lines of the field above
|
||||||
// items, or once the toolbar has been asked for a checklist — in
|
// (M304) — rendering them again down here is what would put every
|
||||||
// which case the add row takes focus, because being asked for a
|
// list on screen twice.
|
||||||
// list means being about to type one.
|
|
||||||
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()) {
|
||||||
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
|
EditorLabelRow(note = note, readOnly = readOnly, onAction = onAction)
|
||||||
@@ -375,6 +359,63 @@ private fun EditorField(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert `- [ ] ` at the caret, on a line of its own.
|
||||||
|
*
|
||||||
|
* The whole of "add a checklist" now. It writes nothing to the store, needs no saved
|
||||||
|
* note, and works on an empty compose box the moment it opens — because an item is
|
||||||
|
* text, and this screen already knows how to hold text.
|
||||||
|
*/
|
||||||
|
private fun insertChecklistMarker(value: TextFieldValue): TextFieldValue {
|
||||||
|
val start = value.selection.start
|
||||||
|
val before = value.text.take(start)
|
||||||
|
// A new line unless the caret is already at the start of one: a marker in the
|
||||||
|
// middle of a sentence is not a list item, it is a typo.
|
||||||
|
val prefix = if (before.isEmpty() || before.endsWith("\n")) "" else "\n"
|
||||||
|
val text = before + prefix + CHECKLIST_MARKER + value.text.substring(start)
|
||||||
|
return TextFieldValue(text, TextRange(start + prefix.length + CHECKLIST_MARKER.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter on a task line starts the next item; on an EMPTY one it ends the list.
|
||||||
|
*
|
||||||
|
* Both halves are needed. Without the first, a list costs six characters a row on a
|
||||||
|
* phone keyboard; without the second, there is no way out of one except deleting the
|
||||||
|
* marker by hand.
|
||||||
|
*
|
||||||
|
* The grammar lives in the core ([checklistContinuation]) rather than here. It is
|
||||||
|
* already written three times — Rust, Python, TypeScript — and a fourth copy in
|
||||||
|
* Compose would be a fourth place for a checklist to change shape when it syncs.
|
||||||
|
*
|
||||||
|
* Recognised by SHAPE rather than by a key event: this runs inside onValueChange, and
|
||||||
|
* a plain Enter is exactly one more character than before, and that character is a
|
||||||
|
* newline. Anything else — a paste, a selection replaced, an autocorrect — falls
|
||||||
|
* through untouched.
|
||||||
|
*/
|
||||||
|
private fun continueChecklist(
|
||||||
|
old: TextFieldValue,
|
||||||
|
new: TextFieldValue,
|
||||||
|
): TextFieldValue {
|
||||||
|
val caret = new.selection.start
|
||||||
|
if (new.selection.length != 0) return new
|
||||||
|
if (new.text.length != old.text.length + 1) return new
|
||||||
|
if (caret == 0 || new.text[caret - 1] != '\n') return new
|
||||||
|
|
||||||
|
val before = new.text.take(caret - 1)
|
||||||
|
val lineStart = before.lastIndexOf('\n') + 1
|
||||||
|
val marker = checklistContinuation(before.substring(lineStart)) ?: return new
|
||||||
|
|
||||||
|
if (marker.isEmpty()) {
|
||||||
|
// An empty item: take the marker line away rather than adding another.
|
||||||
|
val text = new.text.take(lineStart) + new.text.substring(caret)
|
||||||
|
return TextFieldValue(text, TextRange(lineStart))
|
||||||
|
}
|
||||||
|
val text = new.text.take(caret) + marker + new.text.substring(caret)
|
||||||
|
return TextFieldValue(text, TextRange(caret + marker.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val CHECKLIST_MARKER = "- [ ] "
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How long typing has to stop before the note is written.
|
* How long typing has to stop before the note is written.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -13,10 +13,6 @@
|
|||||||
|
|
||||||
<!-- Board -->
|
<!-- Board -->
|
||||||
<string name="board_empty_note">Empty note</string>
|
<string name="board_empty_note">Empty note</string>
|
||||||
<plurals name="board_more_items">
|
|
||||||
<item quantity="one">+%d more item</item>
|
|
||||||
<item quantity="other">+%d more items</item>
|
|
||||||
</plurals>
|
|
||||||
|
|
||||||
<!-- Empty states. Each destination says something true of ITSELF; a single
|
<!-- Empty states. Each destination says something true of ITSELF; a single
|
||||||
"nothing here" reads as encouragement on the board and as a fault in Trash. -->
|
"nothing here" reads as encouragement on the board and as a fault in Trash. -->
|
||||||
|
|||||||
@@ -499,6 +499,40 @@ impl ThoughtSync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── checklist text, as pure functions ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Free functions rather than methods, because these touch no database. The editor's
|
||||||
|
// body field is LOCAL state — it is autosaved on an idle debounce, not written on
|
||||||
|
// every keystroke — so a checkbox tapped in the editor has to rewrite the text the
|
||||||
|
// field is holding, not a row the store would hand back a moment later. Routing that
|
||||||
|
// through the store would overwrite whatever was being typed.
|
||||||
|
//
|
||||||
|
// They also keep the grammar out of Kotlin. Three implementations of it is the price
|
||||||
|
// already being paid (Rust, Python, TypeScript); a fourth in Compose would be one
|
||||||
|
// more place for a checklist to change shape when it syncs.
|
||||||
|
|
||||||
|
/// Which body lines carry checklist items, in item order — so a renderer walking the
|
||||||
|
/// body line by line knows which of them to draw a checkbox on.
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn checklist_lines(body: String) -> Vec<u32> {
|
||||||
|
local::derive::item_lines(&body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The body with the item at `line`/`column` ticked or unticked, or null if that is
|
||||||
|
/// not a checkbox. See `derive::toggle_at` for why the address is line + column and
|
||||||
|
/// not a text offset.
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn checklist_toggle_at(body: String, line: u32, column: u32) -> Option<String> {
|
||||||
|
local::derive::toggle_at(&body, line as usize, column as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What pressing Enter at the end of `line` should leave behind: null to let Enter be
|
||||||
|
/// Enter, "" to end the list, or the marker to start the next item.
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn checklist_continuation(line: String) -> Option<String> {
|
||||||
|
local::derive::continuation(&line)
|
||||||
|
}
|
||||||
|
|
||||||
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
|
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
|
||||||
/// block names, so these stay Rust-side.
|
/// block names, so these stay Rust-side.
|
||||||
impl ThoughtSync {
|
impl ThoughtSync {
|
||||||
|
|||||||
@@ -241,6 +241,69 @@ pub fn remove_item(body: &str, index: usize) -> String {
|
|||||||
map_task_line(body, index, |_| None)
|
map_task_line(body, index, |_| None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The body line each checklist item sits on, in item order.
|
||||||
|
///
|
||||||
|
/// For a renderer that walks the body line by line and has to know which of them are
|
||||||
|
/// items — the Android card does exactly that, and this is what saves it from
|
||||||
|
/// carrying a fourth copy of the grammar. Line numbers rather than text offsets, for
|
||||||
|
/// the same encoding reason [toggle_at] gives.
|
||||||
|
pub fn item_lines(body: &str) -> Vec<u32> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (n, line) in body.split('\n').enumerate() {
|
||||||
|
if parse_task_line(line).is_some() {
|
||||||
|
out.push(n as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The body with the item on `line` toggled — or None when that line is not a task
|
||||||
|
/// line, or when `column` falls outside its `[ ]` marker.
|
||||||
|
///
|
||||||
|
/// Addressed by LINE and COLUMN rather than by a text offset, because the two sides of
|
||||||
|
/// the FFI do not count the same way: Compose measures an offset in UTF-16 units and
|
||||||
|
/// Rust in bytes, so the same number means different places in a note with an emoji in
|
||||||
|
/// it. A line number is identical in every encoding. So is a column inside the marker,
|
||||||
|
/// which is ASCII and sits at the start of its line — and that is the only range this
|
||||||
|
/// function looks at.
|
||||||
|
///
|
||||||
|
/// Only the marker toggles, not the whole line: the rest of it is text somebody needs
|
||||||
|
/// to be able to put a caret into.
|
||||||
|
pub fn toggle_at(body: &str, line: usize, column: usize) -> Option<String> {
|
||||||
|
let target = body.split('\n').nth(line)?;
|
||||||
|
let parsed = parse_task_line(target)?;
|
||||||
|
// One column of slack past the `]`, because a checkbox on a phone should forgive
|
||||||
|
// a near miss.
|
||||||
|
let marker_end = target.chars().position(|c| c == ']')? + 1;
|
||||||
|
if column > marker_end {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut index = 0;
|
||||||
|
for earlier in body.split('\n').take(line) {
|
||||||
|
if parse_task_line(earlier).is_some() {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(set_item_checked(body, index, !parsed.checked))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What pressing Enter at the end of `line` should leave behind.
|
||||||
|
///
|
||||||
|
/// * `None` — not a task line. Enter does what Enter always does.
|
||||||
|
/// * `Some("")` — an EMPTY item: clear the marker and end the list. Without this half
|
||||||
|
/// a list is impossible to get out of without deleting characters by hand.
|
||||||
|
/// * `Some(marker)` — start the next item. The indent and bullet are carried over
|
||||||
|
/// rather than normalised, because continuing someone's `*` list with a `-` is an
|
||||||
|
/// edit they did not ask for.
|
||||||
|
pub fn continuation(line: &str) -> Option<String> {
|
||||||
|
let parsed = parse_task_line(line)?;
|
||||||
|
if parsed.text.trim().is_empty() {
|
||||||
|
return Some(String::new());
|
||||||
|
}
|
||||||
|
Some(format!("{}{} [ ] ", parsed.indent, parsed.bullet))
|
||||||
|
}
|
||||||
|
|
||||||
/// Add an item at the end of the body.
|
/// Add an item at the end of the body.
|
||||||
///
|
///
|
||||||
/// Spaced exactly as `import_export.py:_note_markdown` writes a list — a blank line
|
/// Spaced exactly as `import_export.py:_note_markdown` writes a list — a blank line
|
||||||
@@ -413,6 +476,41 @@ mod tests {
|
|||||||
assert_eq!(strip_marker("just prose"), "just prose");
|
assert_eq!(strip_marker("just prose"), "just prose");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn item_lines_maps_items_to_the_lines_they_sit_on() {
|
||||||
|
assert_eq!(item_lines("a\n- [ ] x\nb\n- [x] y"), vec![1, 3]);
|
||||||
|
assert!(item_lines("no items here").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_at_only_fires_inside_the_marker() {
|
||||||
|
let body = "note\n- [ ] milk\n- [x] eggs";
|
||||||
|
// Column 0 is the bullet, 4 is the `]`, 5 the slack past it.
|
||||||
|
assert_eq!(
|
||||||
|
toggle_at(body, 1, 0).as_deref(),
|
||||||
|
Some("note\n- [x] milk\n- [x] eggs")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
toggle_at(body, 2, 5).as_deref(),
|
||||||
|
Some("note\n- [ ] milk\n- [ ] eggs")
|
||||||
|
);
|
||||||
|
// Past the marker is text someone wants to put a caret in.
|
||||||
|
assert!(toggle_at(body, 1, 9).is_none());
|
||||||
|
// Not a task line, and off the end.
|
||||||
|
assert!(toggle_at(body, 0, 0).is_none());
|
||||||
|
assert!(toggle_at(body, 99, 0).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn continuation_starts_the_next_item_or_ends_the_list() {
|
||||||
|
assert_eq!(continuation("- [x] milk").as_deref(), Some("- [ ] "));
|
||||||
|
assert_eq!(continuation(" * [ ] milk").as_deref(), Some(" * [ ] "));
|
||||||
|
// An empty item ends the list rather than adding another.
|
||||||
|
assert_eq!(continuation("- [ ]").as_deref(), Some(""));
|
||||||
|
assert_eq!(continuation("- [ ] ").as_deref(), Some(""));
|
||||||
|
assert!(continuation("just prose").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_stale_index_does_nothing() {
|
fn a_stale_index_does_nothing() {
|
||||||
// The index comes from a UI that may be a moment behind the store. A tap
|
// The index comes from a UI that may be a moment behind the store. A tap
|
||||||
|
|||||||
Reference in New Issue
Block a user