M12 — the Android client, end to end #2
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in New Issue
Block a user