Checklists in the body, colour from tags, and commit-derived CalVer #4
@@ -26,7 +26,6 @@ import com.fabledsword.thoughtsync.core.ThoughtSync
|
|||||||
import com.fabledsword.thoughtsync.ui.BoardScreen
|
import com.fabledsword.thoughtsync.ui.BoardScreen
|
||||||
import com.fabledsword.thoughtsync.ui.BoardSync
|
import com.fabledsword.thoughtsync.ui.BoardSync
|
||||||
import com.fabledsword.thoughtsync.ui.BoardViewModel
|
import com.fabledsword.thoughtsync.ui.BoardViewModel
|
||||||
import com.fabledsword.thoughtsync.ui.ComposeSheet
|
|
||||||
import com.fabledsword.thoughtsync.ui.ForegroundTransitions
|
import com.fabledsword.thoughtsync.ui.ForegroundTransitions
|
||||||
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
|
import com.fabledsword.thoughtsync.ui.NoteEditorScreen
|
||||||
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
|
import com.fabledsword.thoughtsync.ui.StoreUnavailableScreen
|
||||||
@@ -141,14 +140,13 @@ private fun App(
|
|||||||
val sync: SyncViewModel =
|
val sync: SyncViewModel =
|
||||||
viewModel(factory = SyncViewModel.factory(core, onStoreChanged = board::refresh))
|
viewModel(factory = SyncViewModel.factory(core, onStoreChanged = board::refresh))
|
||||||
|
|
||||||
// Sheet and screen visibility are view STATE, not view-model state: they are
|
// Screen visibility is view STATE, not view-model state: it is about what is on
|
||||||
// about what is on the display, and nothing in the store cares.
|
// 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
|
// The capture sheet used to keep its own flag here too. It is gone: the + button
|
||||||
// was open and took the half-written note in the capture sheet with it. The
|
// opens the editor on an unsaved draft, so writing a note and editing one are the
|
||||||
// editor never had that problem because the note it is on lives in a view
|
// same surface with the same toolbar.
|
||||||
// model; these two are the only screen state that did not.
|
|
||||||
var composing by rememberSaveable { mutableStateOf(false) }
|
|
||||||
var showingSync by rememberSaveable { mutableStateOf(false) }
|
var showingSync by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
|
val update: UpdateViewModel = viewModel(factory = UpdateViewModel.factory(core, context))
|
||||||
@@ -192,6 +190,7 @@ private fun App(
|
|||||||
NoteEditorScreen(
|
NoteEditorScreen(
|
||||||
// Non-null by construction: `screen` is EDITOR only when it is.
|
// Non-null by construction: `screen` is EDITOR only when it is.
|
||||||
note = requireNotNull(editing) { "the editor screen needs a note" },
|
note = requireNotNull(editing) { "the editor screen needs a note" },
|
||||||
|
sessionKey = board.state.editingSession,
|
||||||
labels = board.state.labels,
|
labels = board.state.labels,
|
||||||
saving = board.state.saving,
|
saving = board.state.saving,
|
||||||
error = board.state.error,
|
error = board.state.error,
|
||||||
@@ -218,20 +217,9 @@ private fun App(
|
|||||||
),
|
),
|
||||||
onOpenSync = { showingSync = true },
|
onOpenSync = { showingSync = true },
|
||||||
onSearch = board::search,
|
onSearch = board::search,
|
||||||
onCompose = { composing = true },
|
onCompose = board::compose,
|
||||||
onDismissError = board::dismissError,
|
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.
|
* has to re-query to see its own change.
|
||||||
*/
|
*/
|
||||||
val editing: Note? = null,
|
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. */
|
/** Search overrides the destination while there is a query to run. */
|
||||||
val searching: Boolean get() = query.isNotBlank()
|
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 ──────────────────────────────
|
// ─────────────────────────────── the editor ──────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -238,12 +210,119 @@ class BoardViewModel(
|
|||||||
fun openNoteById(id: String) {
|
fun openNoteById(id: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { withContext(Dispatchers.IO) { core.getNote(id) } }
|
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) {
|
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,
|
note: Note,
|
||||||
action: EditorAction,
|
action: EditorAction,
|
||||||
) {
|
) {
|
||||||
|
if (note.id == DRAFT_ID) {
|
||||||
|
onDraftAction(note, action)
|
||||||
|
return
|
||||||
|
}
|
||||||
val id = note.id
|
val id = note.id
|
||||||
when (action) {
|
when (action) {
|
||||||
EditorAction.Close -> state = state.copy(editing = null)
|
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,
|
// 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.
|
// in the editor — it is something a note HAS, not a different thing to capture.
|
||||||
NoteDraft(body = content, color = DEFAULT_COLOR, items = null)
|
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.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
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.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 kotlinx.coroutines.delay
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The note editor: a full screen, not a sheet.
|
* The note editor: a full screen, not a sheet.
|
||||||
@@ -55,6 +57,7 @@ import com.fabledsword.thoughtsync.core.Note
|
|||||||
@Composable
|
@Composable
|
||||||
fun NoteEditorScreen(
|
fun NoteEditorScreen(
|
||||||
note: Note,
|
note: Note,
|
||||||
|
sessionKey: Long,
|
||||||
labels: List<Label>,
|
labels: List<Label>,
|
||||||
saving: Boolean,
|
saving: Boolean,
|
||||||
error: String?,
|
error: String?,
|
||||||
@@ -63,19 +66,26 @@ fun NoteEditorScreen(
|
|||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val tint = noteTint(note.color)
|
val tint = noteTint(note.color)
|
||||||
|
|
||||||
// Keyed by note id: the editor is reused across notes, and without the key the
|
// Keyed by the SESSION, not by note.id: the editor is reused across notes, so it
|
||||||
// second note opened would show the first one's text.
|
// 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
|
// 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
|
// 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
|
// 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.
|
// writing this note", which is what opening an existing one usually means.
|
||||||
var body by
|
var body by
|
||||||
remember(note.id) {
|
rememberSaveable(sessionKey, stateSaver = TextFieldValue.Saver) {
|
||||||
mutableStateOf(TextFieldValue(note.body, TextRange(note.body.length)))
|
mutableStateOf(TextFieldValue(note.body, TextRange(note.body.length)))
|
||||||
}
|
}
|
||||||
var picker by remember(note.id) { mutableStateOf(Picker.NONE) }
|
var picker by remember(sessionKey) { mutableStateOf(Picker.NONE) }
|
||||||
var confirmingDelete by remember(note.id) { mutableStateOf(false) }
|
var confirmingDelete by remember(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
|
||||||
@@ -104,10 +114,27 @@ fun NoteEditorScreen(
|
|||||||
// you cannot edit is noise. `note.id` as the key so the request fires again
|
// 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.
|
// when the reused editor is pointed at a different note.
|
||||||
val bodyFocus = remember { FocusRequester() }
|
val bodyFocus = remember { FocusRequester() }
|
||||||
LaunchedEffect(note.id) {
|
LaunchedEffect(sessionKey) {
|
||||||
if (!readOnly) bodyFocus.requestFocus()
|
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)
|
BackHandler(onBack = leave)
|
||||||
|
|
||||||
// Leaving the APP is not closing the editor, so the text has to be saved
|
// 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
|
private const val MIN_BODY_LINES = 6
|
||||||
|
|||||||
@@ -10,9 +10,6 @@
|
|||||||
|
|
||||||
<!-- Compose sheet -->
|
<!-- Compose sheet -->
|
||||||
<string name="compose_open">New note</string>
|
<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 -->
|
<!-- Board -->
|
||||||
<string name="board_empty_note">Empty note</string>
|
<string name="board_empty_note">Empty note</string>
|
||||||
@@ -38,7 +35,7 @@
|
|||||||
<string name="board_open_note">Open note</string>
|
<string name="board_open_note">Open note</string>
|
||||||
<string name="editor_back">Back to notes</string>
|
<string name="editor_back">Back to notes</string>
|
||||||
<string name="editor_add_checklist">Add a checklist</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_add_item">Add item</string>
|
||||||
<string name="editor_remove_item">Remove item</string>
|
<string name="editor_remove_item">Remove item</string>
|
||||||
<string name="editor_remove_label">Remove label</string>
|
<string name="editor_remove_label">Remove label</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user