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.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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 // 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. // 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) } val settings = remember(context) { SyncSettings(context) }
var automatic by remember { mutableStateOf(settings.automatic) } 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
@@ -195,10 +194,14 @@ private fun SearchBar(
onMenu: () -> Unit, onMenu: () -> Unit,
) { ) {
Surface( 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 =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = GUTTER, vertical = 8.dp), .padding(horizontal = GUTTER, vertical = 8.dp),
shape = RoundedCornerShape(SEARCH_RADIUS), shape = RoundedCornerShape(SEARCH_RADIUS),
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceVariant,
@@ -19,6 +19,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
@@ -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, * 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 * because on a phone the two are genuinely different typing tasks and switching
* halfway is worse than choosing at the start. * 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -46,17 +61,27 @@ fun ComposeSheet(
onSave: (DraftKind, String, String) -> Unit, onSave: (DraftKind, String, String) -> Unit,
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var kind by remember { mutableStateOf(DraftKind.NOTE) } // Saveable, not just remembered: a rotation mid-sentence is the same lost
var title by remember { mutableStateOf("") } // thought as a discarded one, and it was losing it before this.
var content by remember { mutableStateOf("") } var kind by rememberSaveable { mutableStateOf(DraftKind.NOTE) }
var title by rememberSaveable { mutableStateOf("") }
var content by rememberSaveable { mutableStateOf("") }
val contentFocus = remember { FocusRequester() } 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 // 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 // document, and making someone tab past an optional field is the difference
// between "under a second" and not. // between "under a second" and not.
LaunchedEffect(Unit) { contentFocus.requestFocus() } 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( Column(
modifier = modifier =
Modifier Modifier
@@ -100,8 +125,8 @@ fun ComposeSheet(
) )
SheetActions( SheetActions(
canSave = !saving && (title.isNotBlank() || content.isNotBlank()), canSave = !saving && written,
onCancel = onDismiss, onDiscard = onDismiss,
onSave = { onSave(kind, title, content) }, onSave = { onSave(kind, title, content) },
) )
} }
@@ -111,14 +136,17 @@ fun ComposeSheet(
@Composable @Composable
private fun SheetActions( private fun SheetActions(
canSave: Boolean, canSave: Boolean,
onCancel: () -> Unit, onDiscard: () -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.End, 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) { Button(onClick = onSave, enabled = canSave) {
Text(stringResource(R.string.compose_save)) 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.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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.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
@@ -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. * The title and body fields.
* *
+1 -1
View File
@@ -15,7 +15,7 @@
<string name="compose_title_hint">Title</string> <string name="compose_title_hint">Title</string>
<string name="compose_body_hint">Take a note…</string> <string name="compose_body_hint">Take a note…</string>
<string name="compose_list_hint">One item per line</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> <string name="compose_save">Save</string>
<!-- Board --> <!-- Board -->