M12 — the Android client, end to end #2

Merged
bvandeusen merged 86 commits from dev into main 2026-08-21 08:53:58 -04:00
6 changed files with 86 additions and 41 deletions
Showing only changes of commit 39170b715c - Show all commits
@@ -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 -->