android: leaving the composer keeps the note, and the board loses its dead space
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m23s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (debug APK) (push) Successful in 7m15s

Two things the operator hit on a real device.

**Capture threw work away.** Every exit from the compose sheet except Save
discarded it — tapping the board behind, swiping down, back, backgrounding the
app, and rotating the phone. That is the wrong default anywhere and the worst
possible one here: a sheet that loses a typed thought because you touched
outside it teaches people not to trust the app with a thought, and capture is
the one place this product cannot afford that.

Now every way out saves, which is the shape the editor already settled on. The
difference is that capture also has to be abandonable — tapping + and changing
your mind is normal — so Discard exists and is the only path that loses
anything. It is called Discard rather than Cancel because "cancel" means "undo
what I am doing", which is precisely what leaving no longer does; the word would
have described the one button it is not attached to. An empty draft needs
neither and is simply dropped: a blank note nobody asked for is worse than none.

Backgrounding persists but does NOT close an empty sheet. Someone who tapped +
and got distracted should find the composer where they left it.

Rotation was losing it twice over: the draft was `remember`, and so was the flag
saying the sheet is open. Both are `rememberSaveable` now, along with the sync
screen's — the editor never had the bug because the note it sits on lives in a
view model, and these were the only screen state that did not.

`FlushOnStop` moves out of NoteEditorScreen into its own file; the editor and
the capture sheet want the identical thing for the identical reason, and it was
about to be copied.

**The board had a centimetre of nothing above the search field.** `SearchBar`
applied `statusBarsPadding()` inside a `Scaffold` whose content padding already
carries the system-bar insets — `ScaffoldDefaults.contentWindowInsets` is
`systemBarsForVisualComponents`, checked in the material3 sources rather than
assumed. So the status bar height was reserved twice on the first screen anyone
sees. Insets get consumed once, by whichever component owns the edge.
This commit is contained in:
2026-08-19 19:39:40 -04:00
parent 5680f046e3
commit 39170b715c
6 changed files with 86 additions and 41 deletions
@@ -12,6 +12,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -81,8 +82,13 @@ private fun App(core: ThoughtSync) {
// 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.
var composing by remember { mutableStateOf(false) }
var showingSync by remember { mutableStateOf(false) }
//
// 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) }
var showingSync by rememberSaveable { mutableStateOf(false) }
val settings = remember(context) { SyncSettings(context) }
var automatic by remember { mutableStateOf(settings.automatic) }
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
@@ -195,10 +194,14 @@ private fun SearchBar(
onMenu: () -> Unit,
) {
Surface(
// No `statusBarsPadding()` here. The Scaffold this sits in already applies
// the system-bar insets to its content padding, so adding them again put
// the whole status bar's height of empty space above the search field —
// roughly a centimetre of nothing at the top of the first screen anyone
// sees. Insets get consumed once, by whichever component owns the edge.
modifier =
Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = GUTTER, vertical = 8.dp),
shape = RoundedCornerShape(SEARCH_RADIUS),
color = MaterialTheme.colorScheme.surfaceVariant,
@@ -19,6 +19,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
@@ -37,6 +38,20 @@ import com.fabledsword.thoughtsync.R
* 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
@@ -46,17 +61,27 @@ fun ComposeSheet(
onSave: (DraftKind, String, String) -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var kind by remember { mutableStateOf(DraftKind.NOTE) }
var title by remember { mutableStateOf("") }
var content by remember { mutableStateOf("") }
// Saveable, not just remembered: a rotation mid-sentence is the same lost
// thought as a discarded one, and it was losing it before this.
var kind by rememberSaveable { mutableStateOf(DraftKind.NOTE) }
var title by rememberSaveable { mutableStateOf("") }
var content by rememberSaveable { mutableStateOf("") }
val contentFocus = remember { FocusRequester() }
val written = title.isNotBlank() || content.isNotBlank()
val leave = { if (written) onSave(kind, title, content) else onDismiss() }
// Land in the body, not the title. Most captures are a thought, not a titled
// document, and making someone tab past an optional field is the difference
// between "under a second" and not.
LaunchedEffect(Unit) { contentFocus.requestFocus() }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
// Backgrounding PERSISTS but does not close an empty sheet. Someone who tapped
// + and then got distracted should find the composer where they left it; the
// only reason to act here is that there is something to lose.
FlushOnStop { if (written) onSave(kind, title, content) }
ModalBottomSheet(onDismissRequest = leave, sheetState = sheetState) {
Column(
modifier =
Modifier
@@ -100,8 +125,8 @@ fun ComposeSheet(
)
SheetActions(
canSave = !saving && (title.isNotBlank() || content.isNotBlank()),
onCancel = onDismiss,
canSave = !saving && written,
onDiscard = onDismiss,
onSave = { onSave(kind, title, content) },
)
}
@@ -111,14 +136,17 @@ fun ComposeSheet(
@Composable
private fun SheetActions(
canSave: Boolean,
onCancel: () -> Unit,
onDiscard: () -> Unit,
onSave: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onCancel) { Text(stringResource(R.string.compose_cancel)) }
// "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))
}
@@ -0,0 +1,36 @@
package com.fabledsword.thoughtsync.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
/**
* Run [flush] when the app goes to the background.
*
* `ON_STOP` rather than `ON_PAUSE`: pause also fires when a dialog opens over the
* activity, which would save mid-sentence for no reason. The lambda goes through
* `rememberUpdatedState` so the observer — registered once — always calls the
* CURRENT one; captured directly it would hold the first composition's empty text
* forever and save that over a full note.
*
* Shared by the editor and the capture sheet. Both are places where text exists
* only in a composable until something writes it down, and the process can be
* killed while backgrounded without either of them being told again.
*/
@Composable
fun FlushOnStop(flush: () -> Unit) {
val current by rememberUpdatedState(flush)
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) current()
}
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
}
@@ -24,19 +24,14 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.fabledsword.thoughtsync.R
import com.fabledsword.thoughtsync.core.Label
import com.fabledsword.thoughtsync.core.Note
@@ -254,29 +249,6 @@ private fun EditorOverlays(
}
}
/**
* Run [flush] when the app goes to the background.
*
* `ON_STOP` rather than `ON_PAUSE`: pause also fires when a dialog opens over the
* activity, which would save mid-sentence for no reason. The lambda goes through
* `rememberUpdatedState` so the observer — registered once — always calls the
* CURRENT one; captured directly it would hold the first composition's empty text
* forever and save that over a full note.
*/
@Composable
private fun FlushOnStop(flush: () -> Unit) {
val current by rememberUpdatedState(flush)
val owner = LocalLifecycleOwner.current
DisposableEffect(owner) {
val observer =
LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) current()
}
owner.lifecycle.addObserver(observer)
onDispose { owner.lifecycle.removeObserver(observer) }
}
}
/**
* The title and body fields.
*
+1 -1
View File
@@ -15,7 +15,7 @@
<string name="compose_title_hint">Title</string>
<string name="compose_body_hint">Take a note…</string>
<string name="compose_list_hint">One item per line</string>
<string name="compose_cancel">Cancel</string>
<string name="compose_discard">Discard</string>
<string name="compose_save">Save</string>
<!-- Board -->