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