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.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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user