From ce6a1093a3cc59e218fa1b1ce141b6ad0cc82f99 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 23 Aug 2026 22:14:02 -0400 Subject: [PATCH] android: writing a note and editing one are the same surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The + button raised a capture sheet with a single text field. The editor is a screen with a toolbar. So a note being WRITTEN could not be given a colour, a reminder or a checklist — those live on the toolbar, and the sheet had none. To make a checklist you wrote a note, saved it, reopened it, and found a control you had never seen. ComposeSheet is deleted. + opens the editor on an unsaved draft. A draft is a real Note carrying DRAFT_ID (the empty string) rather than a null. Note has eighteen fields and the editor reads eight of them; threading nullability through all of that to express "not saved yet" would spread the concept across a screen that should not have to know about it. A real id is a uuid, so the sentinel cannot collide. It becomes a row on its first save, and the first save is now an autosave: the editor writes a second after typing stops. That is affordable because 2707054 made a body write stop costing a revision — before it, saving this often would have meant a revision per second. Autosave is also what makes materialisation work at all. Creating the note on a toolbar tap instead races: the typed text lives in the field's own state and only reaches the view model on flush, so the tap would create an EMPTY note and lose what was written. With a one-second debounce the note already exists by the time any button is reachable. Three consequences worth naming: - editingSession, bumped only when the editor opens on a DIFFERENT note. The text field keys on it instead of note.id, because a draft's id changes the moment it is first saved and re-keying on that would reset the field to whatever the store just returned — discarding everything typed during the write. - The field is rememberSaveable now. A new note has nothing to fall back on, and the old sheet used rememberSaveable for exactly this reason; the editor inherits the requirement along with the job. - draftDismissed, so a create still in flight cannot reopen an editor the user has already closed. Starting a checklist may create an empty note — a note named from its first item is one this app already has. Colour and reminder are attributes OF a note and need words first. editor_body_hint becomes "Take a note…". It read "Note", which is a label on a blank screen where the sheet's was an invitation. Co-Authored-By: Claude Opus 5 (1M context) --- .../fabledsword/thoughtsync/MainActivity.kt | 28 +-- .../thoughtsync/ui/BoardViewModel.kt | 193 ++++++++++++++---- .../thoughtsync/ui/ComposeSheet.kt | 130 ------------ .../thoughtsync/ui/NoteEditorScreen.kt | 48 ++++- android/app/src/main/res/values/strings.xml | 5 +- 5 files changed, 204 insertions(+), 200 deletions(-) delete mode 100644 android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt index d5d35a1..bd691e6 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/MainActivity.kt @@ -26,7 +26,6 @@ import com.fabledsword.thoughtsync.core.ThoughtSync import com.fabledsword.thoughtsync.ui.BoardScreen import com.fabledsword.thoughtsync.ui.BoardSync import com.fabledsword.thoughtsync.ui.BoardViewModel -import com.fabledsword.thoughtsync.ui.ComposeSheet import com.fabledsword.thoughtsync.ui.ForegroundTransitions import com.fabledsword.thoughtsync.ui.NoteEditorScreen import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen @@ -141,14 +140,13 @@ private fun App( val sync: SyncViewModel = viewModel(factory = SyncViewModel.factory(core, onStoreChanged = board::refresh)) - // Sheet and screen visibility are view STATE, not view-model state: they are - // about what is on the display, and nothing in the store cares. + // Screen visibility is view STATE, not view-model state: it is about what is on + // the display, and nothing in the store cares. Saveable so a rotation does not + // close it. // - // Saveable, though: `remember` alone meant rotating the phone closed whatever - // was open and took the half-written note in the capture sheet with it. The - // editor never had that problem because the note it is on lives in a view - // model; these two are the only screen state that did not. - var composing by rememberSaveable { mutableStateOf(false) } + // The capture sheet used to keep its own flag here too. It is gone: the + button + // opens the editor on an unsaved draft, so writing a note and editing one are the + // same surface with the same toolbar. var showingSync by rememberSaveable { mutableStateOf(false) } val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context)) @@ -192,6 +190,7 @@ private fun App( NoteEditorScreen( // Non-null by construction: `screen` is EDITOR only when it is. note = requireNotNull(editing) { "the editor screen needs a note" }, + sessionKey = board.state.editingSession, labels = board.state.labels, saving = board.state.saving, error = board.state.error, @@ -218,20 +217,9 @@ private fun App( ), onOpenSync = { showingSync = true }, onSearch = board::search, - onCompose = { composing = true }, + onCompose = board::compose, onDismissError = board::dismissError, ) - - if (composing) { - ComposeSheet( - saving = board.state.saving, - onDismiss = { composing = false }, - onSave = { content -> - board.create(content) - composing = false - }, - ) - } } } diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt index a76bf5f..eb18701 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/BoardViewModel.kt @@ -68,6 +68,16 @@ data class BoardState( * has to re-query to see its own change. */ val editing: Note? = null, + /** + * Bumped each time the editor is opened on a DIFFERENT note, and deliberately + * not when the note it is already on changes. + * + * The editor keys its text field on this rather than on `editing.id`, because a + * draft's id changes the instant it is first saved — and re-keying on that would + * reset the field to whatever the store just returned, discarding anything typed + * during the write. That is a data-loss bug rather than a flicker. + */ + val editingSession: Long = 0, ) { /** Search overrides the destination while there is a query to run. */ val searching: Boolean get() = query.isNotBlank() @@ -188,44 +198,6 @@ class BoardViewModel( } } - /** - * Save a new note or list. - * - * Blank input is ignored rather than rejected: an empty save is a slip, not a - * mistake worth interrupting someone over. - */ - fun create(content: String) { - val cleanContent = content.trim() - if (cleanContent.isEmpty()) return - - viewModelScope.launch { - state = state.copy(saving = true) - state = - try { - val created = withContext(Dispatchers.IO) { core.createNote(draft(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 - // showing plain notes — a note created while looking at Trash - // does not belong in that list. - val notes = - if (state.destination == Destination.Notes && !state.searching) { - listOf(created) + state.notes - } else { - state.notes - } - // A capture sheet can carry a reminder in its text one day; - // more to the point, this is a store write and the rule here is - // that every store write re-derives the alarm rather than each - // call site deciding whether its particular write could matter. - withContext(Dispatchers.IO) { onRemindersChanged() } - state.copy(notes = notes, saving = false, error = null) - } catch (e: Exception) { - state.copy(saving = false, error = e.message ?: FALLBACK_ERROR) - } - } - } - // ─────────────────────────────── the editor ────────────────────────────── /** @@ -238,12 +210,119 @@ class BoardViewModel( fun openNoteById(id: String) { viewModelScope.launch { runCatching { withContext(Dispatchers.IO) { core.getNote(id) } } - .onSuccess { state = state.copy(editing = it) } + .onSuccess { state = state.copy(editing = it, editingSession = state.editingSession + 1) } } } fun openNote(note: Note) { - state = state.copy(editing = note) + state = state.copy(editing = note, editingSession = state.editingSession + 1) + } + + /** + * Open the editor on a note that does not exist yet. + * + * The + button used to raise a separate capture sheet, which meant a note being + * WRITTEN could not be given a colour, a reminder or a checklist — those live on + * the editor's toolbar, and the sheet had none. Writing and editing are now the + * same surface. + * + * The draft is a real [Note] carrying [DRAFT_ID] rather than a null, so the + * editor renders it without knowing that "not saved yet" is a state it can be + * in. It becomes a row on its first save; see [onDraftAction]. + */ + fun compose() { + draftDismissed = false + state = state.copy(editing = blankDraft(), editingSession = state.editingSession + 1) + } + + /** + * Set when a draft's editor closes, so a create still in flight does not reopen + * it. The editor flushes its text and then closes, and the flush is a coroutine — + * without this the note would be created, the screen would close, and the create + * would finish and put the screen back. + */ + private var draftDismissed = false + + /** + * The editor's actions, for a note that has no row yet. + * + * Everything a toolbar button does needs an id to act on, so the first action + * that needs one creates the note and replays itself against the real thing. + */ + private fun onDraftAction( + draft: Note, + action: EditorAction, + ) { + when (action) { + // Nothing exists, so leaving leaves nothing behind — which is what makes + // tapping + and changing your mind free. Text typed before this point has + // already gone to createFromDraft via the editor's autosave or its flush. + EditorAction.Close, EditorAction.Trash -> { + draftDismissed = true + state = state.copy(editing = null) + } + EditorAction.DismissError -> dismissError() + is EditorAction.SaveText -> createFromDraft(action.body) + // Starting a checklist 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 (it is named from its first item). So it may + // create an empty one — anything else needs words first. + EditorAction.AddChecklist -> + createFromDraft(draft.body, allowEmpty = true) { created -> + onEditorAction(created, action) + } + // 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 + // anyone reaches the toolbar; before that there is nothing to attribute. + else -> createFromDraft(draft.body) { created -> onEditorAction(created, action) } + } + } + + /** + * Turn a draft into a row, and keep the editor on it. + * + * Adopting the created note is what lets a session of autosaves stay one note: + * the second save sees a real id and updates rather than creating again. + */ + private fun createFromDraft( + content: String, + allowEmpty: Boolean = false, + then: (Note) -> Unit = {}, + ) { + val cleanContent = content.trim() + // A blank draft is not a note. Ignored rather than rejected: tapping + and + // walking away is a slip, not a mistake worth interrupting someone over. + if (cleanContent.isEmpty() && !allowEmpty) return + viewModelScope.launch { + state = state.copy(saving = true) + state = + try { + val created = withContext(Dispatchers.IO) { core.createNote(draft(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 showing + // plain notes — a note created while looking at Trash does not + // belong in that list. + val notes = + if (state.destination == Destination.Notes && !state.searching) { + listOf(created) + state.notes + } else { + state.notes + } + withContext(Dispatchers.IO) { onRemindersChanged() } + // editingSession is NOT bumped: this is the same sitting, and the + // editor's field must not be re-keyed underneath the typing. + state.copy( + notes = notes, + editing = if (draftDismissed) state.editing else created, + saving = false, + error = null, + ) + } catch (e: Exception) { + state.copy(saving = false, error = e.message ?: FALLBACK_ERROR) + } + if (!draftDismissed) state.editing?.let(then) + } } /** @@ -268,6 +347,10 @@ class BoardViewModel( note: Note, action: EditorAction, ) { + if (note.id == DRAFT_ID) { + onDraftAction(note, action) + return + } val id = note.id when (action) { EditorAction.Close -> state = state.copy(editing = null) @@ -447,3 +530,33 @@ private fun draft(content: String): NoteDraft = // findable without anyone being asked to name it. A checklist is added afterwards, // in the editor — it is something a note HAS, not a different thing to capture. NoteDraft(body = content, color = DEFAULT_COLOR, items = null) + +/** + * The id a note has before it has been saved. + * + * A real id is a uuid, so the empty string cannot collide with one. Using a sentinel + * rather than making the editor's note nullable keeps "not saved yet" out of a screen + * that reads eight fields off the note and should not have to null-check any of them. + */ +internal const val DRAFT_ID = "" + +private fun blankDraft(): Note = + Note( + id = DRAFT_ID, + displayTitle = "", + body = "", + color = DEFAULT_COLOR, + position = 0, + pinned = false, + archived = false, + trashed = false, + deletedAt = null, + remindAt = null, + recurrence = null, + labels = emptyList(), + items = emptyList(), + attachments = emptyList(), + previews = emptyList(), + createdAt = null, + updatedAt = null, + ) diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt deleted file mode 100644 index 4d86514..0000000 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/ComposeSheet.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.fabledsword.thoughtsync.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState -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.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.fabledsword.thoughtsync.R - -/** - * The new-note surface, opened by the + button. - * - * A bottom sheet rather than a full screen: capture should feel like a quick aside - * from the board, not a place you navigate to and have to come back from. The - * board stays visible behind it, so the note lands somewhere you can already see. - * - * It asks note-or-list up front rather than making that a mode you discover later, - * because on a phone the two are genuinely different typing tasks and switching - * halfway is worse than choosing at the start. - * - * ## Leaving keeps what you wrote - * - * Every way out of this sheet except Discard SAVES: the save button, tapping the - * board behind it, swiping down, back, and the app being backgrounded. A sheet - * that throws away a typed thought because you touched outside it is a sheet that - * teaches people not to trust the app with a thought — and capture is the one - * place this product cannot afford that. - * - * The same shape the editor settled on, for the same reason, with one difference: - * capture also has to be abandonable, because tapping + and changing your mind is - * a normal thing to do. That is what Discard is, and it is the only path that - * loses anything. An empty draft needs neither — it is simply dropped, since a - * blank note nobody asked for is worse than no note at all. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ComposeSheet( - saving: Boolean, - onDismiss: () -> Unit, - onSave: (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 content by rememberSaveable { mutableStateOf("") } - val contentFocus = remember { FocusRequester() } - - val written = content.isNotBlank() - val leave = { if (written) onSave(content) else onDismiss() } - - // Straight into the one field there is. A capture is a thought, and every field - // someone has to tab past is the difference between "under a second" and not — - // which is why the title field is gone rather than merely skipped (M13 step 3). - LaunchedEffect(Unit) { contentFocus.requestFocus() } - - // 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(content) } - - ModalBottomSheet(onDismissRequest = leave, sheetState = sheetState) { - Column( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .imePadding() - .navigationBarsPadding(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // 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 = content, - onValueChange = { content = it }, - modifier = Modifier.focusRequester(contentFocus), - hint = R.string.compose_body_hint, - minLines = MIN_CONTENT_LINES, - ) - - SheetActions( - canSave = !saving && written, - onDiscard = onDismiss, - onSave = { onSave(content) }, - ) - } - } -} - -@Composable -private fun SheetActions( - canSave: Boolean, - onDiscard: () -> Unit, - onSave: () -> Unit, -) { - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - horizontalArrangement = Arrangement.End, - ) { - // "Discard", not "Cancel". Cancel means "undo what I am doing", which is - // precisely what leaving no longer does — the word would now describe the - // one button it is NOT attached to. - TextButton(onClick = onDiscard) { Text(stringResource(R.string.compose_discard)) } - Button(onClick = onSave, enabled = canSave) { - Text(stringResource(R.string.compose_save)) - } - } -} - -private const val MIN_CONTENT_LINES = 4 diff --git a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt index 6e4c4a5..6edbf02 100644 --- a/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt +++ b/android/app/src/main/java/com/fabledsword/thoughtsync/ui/NoteEditorScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -38,6 +39,7 @@ import androidx.compose.ui.unit.dp import com.fabledsword.thoughtsync.R import com.fabledsword.thoughtsync.core.Label import com.fabledsword.thoughtsync.core.Note +import kotlinx.coroutines.delay /** * The note editor: a full screen, not a sheet. @@ -55,6 +57,7 @@ import com.fabledsword.thoughtsync.core.Note @Composable fun NoteEditorScreen( note: Note, + sessionKey: Long, labels: List