android: writing a note and editing one are the same surface
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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<Label>,
|
||||
saving: Boolean,
|
||||
error: String?,
|
||||
@@ -63,19 +66,26 @@ fun NoteEditorScreen(
|
||||
val dark = isSystemInDarkTheme()
|
||||
val tint = noteTint(note.color)
|
||||
|
||||
// Keyed by note id: the editor is reused across notes, and without the key the
|
||||
// second note opened would show the first one's text.
|
||||
// Keyed by the SESSION, not by note.id: the editor is reused across notes, so it
|
||||
// needs a key — but a draft's id changes the moment it is first saved, and
|
||||
// re-keying on that would reset this field to whatever the store just returned,
|
||||
// throwing away every character typed during the write.
|
||||
//
|
||||
// Saveable, because a new note has nothing to fall back on. Rotating the phone
|
||||
// mid-capture used to be survivable only in the old capture sheet, which used
|
||||
// rememberSaveable for exactly this reason; the editor inherits the requirement
|
||||
// along with the job.
|
||||
//
|
||||
// TextFieldValue rather than String so the CARET can start at the end of the
|
||||
// text. A String field always begins its selection at offset zero, which would
|
||||
// drop the cursor before the first character — the wrong place for "carry on
|
||||
// writing this note", which is what opening an existing one usually means.
|
||||
var body by
|
||||
remember(note.id) {
|
||||
rememberSaveable(sessionKey, stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue(note.body, TextRange(note.body.length)))
|
||||
}
|
||||
var picker by remember(note.id) { mutableStateOf(Picker.NONE) }
|
||||
var confirmingDelete by remember(note.id) { mutableStateOf(false) }
|
||||
var picker by remember(sessionKey) { mutableStateOf(Picker.NONE) }
|
||||
var confirmingDelete by remember(sessionKey) { mutableStateOf(false) }
|
||||
|
||||
// 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
|
||||
@@ -104,10 +114,27 @@ fun NoteEditorScreen(
|
||||
// you cannot edit is noise. `note.id` as the key so the request fires again
|
||||
// when the reused editor is pointed at a different note.
|
||||
val bodyFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(note.id) {
|
||||
LaunchedEffect(sessionKey) {
|
||||
if (!readOnly) bodyFocus.requestFocus()
|
||||
}
|
||||
|
||||
// Idle-debounced autosave. LaunchedEffect cancels and restarts on every
|
||||
// keystroke, so the delay only ever elapses once typing stops.
|
||||
//
|
||||
// Saving this often is affordable because a body write no longer costs a
|
||||
// revision: history snapshots once per editing session rather than once per
|
||||
// save. Before that, writing was expensive enough that this editor hoarded
|
||||
// text until it closed — and an app kill mid-session lost the lot.
|
||||
//
|
||||
// For a note that does not exist yet this is also what CREATES it, which is why
|
||||
// every toolbar button works moments after the first keystroke rather than
|
||||
// needing the note to be saved by hand first.
|
||||
LaunchedEffect(body.text, sessionKey) {
|
||||
if (readOnly || body.text == note.body) return@LaunchedEffect
|
||||
delay(AUTOSAVE_IDLE_MS)
|
||||
onAction(EditorAction.SaveText(body.text))
|
||||
}
|
||||
|
||||
BackHandler(onBack = leave)
|
||||
|
||||
// Leaving the APP is not closing the editor, so the text has to be saved
|
||||
@@ -303,4 +330,13 @@ private fun EditorField(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* How long typing has to stop before the note is written.
|
||||
*
|
||||
* Long enough that a normal sentence is one write, short enough that nothing
|
||||
* meaningful is at risk if the app dies. The flush on close and [FlushOnStop] still
|
||||
* cover the window between the last keystroke and this elapsing.
|
||||
*/
|
||||
private const val AUTOSAVE_IDLE_MS = 1_000L
|
||||
|
||||
private const val MIN_BODY_LINES = 6
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
|
||||
<!-- Compose sheet -->
|
||||
<string name="compose_open">New note</string>
|
||||
<string name="compose_body_hint">Take a note…</string>
|
||||
<string name="compose_discard">Discard</string>
|
||||
<string name="compose_save">Save</string>
|
||||
|
||||
<!-- Board -->
|
||||
<string name="board_empty_note">Empty note</string>
|
||||
@@ -38,7 +35,7 @@
|
||||
<string name="board_open_note">Open note</string>
|
||||
<string name="editor_back">Back to notes</string>
|
||||
<string name="editor_add_checklist">Add a checklist</string>
|
||||
<string name="editor_body_hint">Note</string>
|
||||
<string name="editor_body_hint">Take a note…</string>
|
||||
<string name="editor_add_item">Add item</string>
|
||||
<string name="editor_remove_item">Remove item</string>
|
||||
<string name="editor_remove_label">Remove label</string>
|
||||
|
||||
Reference in New Issue
Block a user