A checklist is something a note has, not something a note is
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 8s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 13s
Android / Kotlin + Rust (APK) (push) Failing after 1m43s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 9s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 8s
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 13s
Android / Kotlin + Rust (APK) (push) Failing after 1m43s
`kind` was never a type. A plain TEXT column with no enum and no CHECK behind
it, compared against a hardcoded ("text", "list") tuple in six places;
`note_items` was always an ordinary child table keyed by note_id; serialization
already emitted `items` whatever the kind; and the Android editor already
toggled between the two losslessly, saying so in a comment. The storage has
modelled "a body plus optional checkable items" the whole time. This deletes the
gates that forbade it.
Every surface: the create/PATCH gates, the ?kind= filter and its saved-filter
facet, the three import/export branches, the column (alembic 0025); the core's
`kind` field, its SQLite column (user_version 6), the sync wire, push and pull;
the FFI records and `NoteEdit::Kind`; and on Android `NoteKind.kt`, `DraftKind`,
the compose sheet's Note/List switch, and the branches in the card, the editor
and the chrome.
The editor's note⇄list toggle becomes "Add a checklist" — on both the web and
Android. It is not a conversion any more: nothing moves, nothing is swapped, the
body stays exactly where it is and the note gains somewhere to put items. The
card renders both, in order.
Two things that fell out of the merge rather than being aimed at:
- The Keep importer was DISCARDING `textContent` whenever a note also had
`listContent`, because the target could only hold one. Both survive now, and
the test says so.
- Markdown export wrote the body OR the checklist. It writes both.
Protocol goes to v2, floor included: dropping a field a v1 client sends and
expects back is breaking. `title` leaves in step 3 and lands in the same
generation, so it needs no further bump. This is the change that will make the
0.1.227 build on the operator's phone refuse to sync — the in-app updater is
independent of the handshake and remains the recovery path.
The V1 SQLite schema deliberately KEEPS the kind column. V1 is the historical
schema and every later block alters it, so removing it there would make a fresh
database run V1 without the column and then v6's DROP COLUMN against a column
that never existed — "no such column: kind" on every new install.
This commit is contained in:
@@ -226,8 +226,8 @@ private fun App(
|
||||
ComposeSheet(
|
||||
saving = board.state.saving,
|
||||
onDismiss = { composing = false },
|
||||
onSave = { kind, title, content ->
|
||||
board.create(kind, title, content)
|
||||
onSave = { title, content ->
|
||||
board.create(title, content)
|
||||
composing = false
|
||||
},
|
||||
)
|
||||
|
||||
@@ -50,9 +50,6 @@ sealed interface Destination {
|
||||
) : Destination
|
||||
}
|
||||
|
||||
/** What kind of thing the compose sheet is making. */
|
||||
enum class DraftKind { NOTE, LIST }
|
||||
|
||||
/** Everything the board renders from, in one immutable snapshot. */
|
||||
data class BoardState(
|
||||
val destination: Destination = Destination.Notes,
|
||||
@@ -198,7 +195,6 @@ class BoardViewModel(
|
||||
* mistake worth interrupting someone over.
|
||||
*/
|
||||
fun create(
|
||||
kind: DraftKind,
|
||||
title: String,
|
||||
content: String,
|
||||
) {
|
||||
@@ -210,7 +206,7 @@ class BoardViewModel(
|
||||
state = state.copy(saving = true)
|
||||
state =
|
||||
try {
|
||||
val created = withContext(Dispatchers.IO) { core.createNote(draft(kind, cleanTitle, cleanContent)) }
|
||||
val created = withContext(Dispatchers.IO) { core.createNote(draft(cleanTitle, cleanContent)) }
|
||||
// Prepend rather than reload: the new note belongs at the top
|
||||
// of the board, and a full re-query would cost a round trip to
|
||||
// tell us what we already know. Skipped when the board is not
|
||||
@@ -306,9 +302,6 @@ class BoardViewModel(
|
||||
|
||||
is EditorAction.SetColor -> edit(id, NoteEdit.Color(action.color))
|
||||
|
||||
EditorAction.ToggleKind ->
|
||||
edit(id, NoteEdit.Kind(if (note.kind == KIND_LIST) KIND_TEXT else KIND_LIST))
|
||||
|
||||
// Pinning re-sorts the board rather than emptying it, and on a phone
|
||||
// you often pin while still reading — so unlike the three below, it
|
||||
// deliberately leaves the editor open.
|
||||
@@ -331,6 +324,10 @@ class BoardViewModel(
|
||||
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 ->
|
||||
action.text.trim().takeIf { it.isNotEmpty() }?.let { text ->
|
||||
mutate { it.addItem(id, text) }
|
||||
@@ -468,25 +465,11 @@ private fun query(
|
||||
) = NoteQuery(view = view, labelId = labelId, sort = null, facets = null)
|
||||
|
||||
private fun draft(
|
||||
kind: DraftKind,
|
||||
title: String,
|
||||
content: String,
|
||||
): NoteDraft =
|
||||
when (kind) {
|
||||
// Body left to carry the text; the core derives display_title from its
|
||||
// first line when no title was given, so a captured thought is nameable
|
||||
// without making the user name it.
|
||||
DraftKind.NOTE ->
|
||||
NoteDraft(title = title, body = content, color = DEFAULT_COLOR, kind = null, items = null)
|
||||
// One line per item. At CAPTURE time the whole list is already in your
|
||||
// head, so typing it in one go beats a tap between each row; the editor
|
||||
// has the per-row control for when the list is being revised instead.
|
||||
DraftKind.LIST ->
|
||||
NoteDraft(
|
||||
title = title,
|
||||
body = "",
|
||||
color = DEFAULT_COLOR,
|
||||
kind = KIND_LIST,
|
||||
items = content.lines().map { it.trim() }.filter { it.isNotEmpty() },
|
||||
)
|
||||
}
|
||||
// Body carries the text; the core derives display_title from its first line when
|
||||
// no title was given, so a captured thought is nameable without making the user
|
||||
// name it. A checklist is added afterwards, in the editor — it is something a note
|
||||
// HAS, not a different thing to capture (M13 step 2).
|
||||
NoteDraft(title = title, body = content, color = DEFAULT_COLOR, items = null)
|
||||
|
||||
@@ -58,18 +58,17 @@ import com.fabledsword.thoughtsync.R
|
||||
fun ComposeSheet(
|
||||
saving: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (DraftKind, String, String) -> Unit,
|
||||
onSave: (String, String) -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
// Saveable, not just remembered: a rotation mid-sentence is the same lost
|
||||
// thought as a discarded one, and it was losing it before this.
|
||||
var kind by rememberSaveable { mutableStateOf(DraftKind.NOTE) }
|
||||
var title by rememberSaveable { mutableStateOf("") }
|
||||
var content by rememberSaveable { mutableStateOf("") }
|
||||
val contentFocus = remember { FocusRequester() }
|
||||
|
||||
val written = title.isNotBlank() || content.isNotBlank()
|
||||
val leave = { if (written) onSave(kind, title, content) else onDismiss() }
|
||||
val leave = { if (written) onSave(title, content) else onDismiss() }
|
||||
|
||||
// Land in the body, not the title. Most captures are a thought, not a titled
|
||||
// document, and making someone tab past an optional field is the difference
|
||||
@@ -79,7 +78,7 @@ fun ComposeSheet(
|
||||
// Backgrounding PERSISTS but does not close an empty sheet. Someone who tapped
|
||||
// + and then got distracted should find the composer where they left it; the
|
||||
// only reason to act here is that there is something to lose.
|
||||
FlushOnStop { if (written) onSave(kind, title, content) }
|
||||
FlushOnStop { if (written) onSave(title, content) }
|
||||
|
||||
ModalBottomSheet(onDismissRequest = leave, sheetState = sheetState) {
|
||||
Column(
|
||||
@@ -91,19 +90,8 @@ fun ComposeSheet(
|
||||
.navigationBarsPadding(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
FilterChip(
|
||||
selected = kind == DraftKind.NOTE,
|
||||
onClick = { kind = DraftKind.NOTE },
|
||||
label = { Text(stringResource(R.string.compose_kind_note)) },
|
||||
)
|
||||
FilterChip(
|
||||
selected = kind == DraftKind.LIST,
|
||||
onClick = { kind = DraftKind.LIST },
|
||||
label = { Text(stringResource(R.string.compose_kind_list)) },
|
||||
)
|
||||
}
|
||||
|
||||
// No note/list switch any more: there is one thing to capture. A
|
||||
// checklist is added to a note in the editor, once there is a note.
|
||||
PlainTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
@@ -115,19 +103,14 @@ fun ComposeSheet(
|
||||
value = content,
|
||||
onValueChange = { content = it },
|
||||
modifier = Modifier.focusRequester(contentFocus),
|
||||
hint =
|
||||
if (kind == DraftKind.LIST) {
|
||||
R.string.compose_list_hint
|
||||
} else {
|
||||
R.string.compose_body_hint
|
||||
},
|
||||
hint = R.string.compose_body_hint,
|
||||
minLines = MIN_CONTENT_LINES,
|
||||
)
|
||||
|
||||
SheetActions(
|
||||
canSave = !saving && written,
|
||||
onDiscard = onDismiss,
|
||||
onSave = { onSave(kind, title, content) },
|
||||
onSave = { onSave(title, content) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,13 +31,13 @@ sealed interface EditorAction {
|
||||
) : EditorAction
|
||||
|
||||
/**
|
||||
* Note ⇄ checklist.
|
||||
* Give this note a checklist.
|
||||
*
|
||||
* Only `kind` changes: the body text and any existing items both stay where
|
||||
* they are, so switching back and forth is lossless and a mis-tap costs
|
||||
* nothing.
|
||||
* 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 ToggleKind : EditorAction
|
||||
data object AddChecklist : EditorAction
|
||||
|
||||
data class SetPinned(
|
||||
val pinned: Boolean,
|
||||
|
||||
@@ -84,15 +84,16 @@ fun EditorBottomBar(
|
||||
contentDescription = stringResource(R.string.editor_reminder),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onAction(EditorAction.ToggleKind) }) {
|
||||
val list = note.kind == KIND_LIST
|
||||
Icon(
|
||||
if (list) Icons.Filled.Create else Icons.AutoMirrored.Filled.List,
|
||||
contentDescription =
|
||||
stringResource(
|
||||
if (list) R.string.editor_make_note else R.string.editor_make_list,
|
||||
),
|
||||
)
|
||||
// Adds the first checklist item, which is what makes the checklist
|
||||
// editor appear. Hidden once the note already has one — there is nothing
|
||||
// left to add that the checklist's own "+" row doesn't do better.
|
||||
if (note.items.isEmpty()) {
|
||||
IconButton(onClick = { onAction(EditorAction.AddChecklist) }) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.List,
|
||||
contentDescription = stringResource(R.string.editor_add_checklist),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,9 +64,8 @@ fun NoteCard(
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
if (note.kind == KIND_LIST) {
|
||||
Checklist(items = note.items)
|
||||
} else if (note.body.isNotBlank()) {
|
||||
// Both, in order — a note can carry a body AND a checklist (M13 step 2).
|
||||
if (note.body.isNotBlank()) {
|
||||
Text(
|
||||
text = note.body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -74,6 +73,10 @@ fun NoteCard(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (note.items.isNotEmpty()) {
|
||||
if (note.body.isNotBlank()) Spacer(Modifier.height(4.dp))
|
||||
Checklist(items = note.items)
|
||||
}
|
||||
|
||||
// A note with no title, no body and no items still has to occupy the
|
||||
// board legibly — otherwise it reads as a rendering bug.
|
||||
|
||||
@@ -150,16 +150,18 @@ fun NoteEditorScreen(
|
||||
bold = true,
|
||||
)
|
||||
|
||||
if (note.kind == KIND_LIST) {
|
||||
EditorField(
|
||||
value = body,
|
||||
onValueChange = { body = it },
|
||||
hint = R.string.editor_body_hint,
|
||||
enabled = !readOnly,
|
||||
minLines = MIN_BODY_LINES,
|
||||
)
|
||||
|
||||
// Below the body, not instead of it, and only once the note has items —
|
||||
// the toolbar's add-checklist action is what puts the first one there.
|
||||
if (note.items.isNotEmpty()) {
|
||||
ChecklistEditor(note = note, readOnly = readOnly, onAction = onAction)
|
||||
} else {
|
||||
EditorField(
|
||||
value = body,
|
||||
onValueChange = { body = it },
|
||||
hint = R.string.editor_body_hint,
|
||||
enabled = !readOnly,
|
||||
minLines = MIN_BODY_LINES,
|
||||
)
|
||||
}
|
||||
|
||||
if (note.labels.isNotEmpty()) {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
package com.fabledsword.thoughtsync.ui
|
||||
|
||||
/**
|
||||
* The core's `kind` vocabulary, which the UI has to match exactly.
|
||||
*
|
||||
* Shared rather than repeated because it was already living in three places — the
|
||||
* card deciding whether to draw checkboxes, the editor deciding which field to
|
||||
* show, and the view model deciding what to create — and a typo in any one of them
|
||||
* would silently render a checklist as a paragraph rather than fail.
|
||||
*
|
||||
* Strings and not an enum: this is a value the STORE owns, arriving from a server
|
||||
* that may be newer than this client, and an unrecognised kind has to fall through
|
||||
* to "render it as a note" rather than throw.
|
||||
*/
|
||||
internal const val KIND_TEXT = "text"
|
||||
internal const val KIND_LIST = "list"
|
||||
Reference in New Issue
Block a user