FlushOnStop was ForegroundTransitions with only its ON_STOP half, so it goes
and the editor calls ForegroundTransitions(onBackground = flush); both halves
now default to nothing, dropping three onBackground = {}. AutomaticUpdate and
AutomaticSync each kept a wanted flag set on the way in and consumed in a
LaunchedEffect once ready; that latch is OnEachForeground(ready, onBackground,
act), with each caller's ready and its reason kept where they were.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -430,7 +430,7 @@ private fun ReminderAlarms(core: Inkwell) {
|
||||
refresh()
|
||||
}
|
||||
|
||||
ForegroundTransitions(onForeground = { refresh() }, onBackground = {})
|
||||
ForegroundTransitions(onForeground = { refresh() })
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// TIRAMISU is where POST_NOTIFICATIONS became a runtime permission. Below
|
||||
@@ -461,17 +461,9 @@ private fun AutomaticUpdate(
|
||||
linked: Boolean,
|
||||
onCheck: () -> Unit,
|
||||
) {
|
||||
var wanted by remember { mutableStateOf(false) }
|
||||
ForegroundTransitions(onForeground = { wanted = true }, onBackground = {})
|
||||
|
||||
LaunchedEffect(wanted, linked) {
|
||||
if (!wanted || !linked) return@LaunchedEffect
|
||||
// Consumed here, so this fires once per trip to the foreground however many
|
||||
// times the effect restarts. There is no suspension point before the call, so
|
||||
// the block completes before the recomposition that would cancel it.
|
||||
wanted = false
|
||||
onCheck()
|
||||
}
|
||||
// Waits for a link rather than dropping the trip: linking a device while the app is
|
||||
// in front checks as soon as it is linked.
|
||||
OnEachForeground(ready = linked, act = onCheck)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -508,28 +500,18 @@ private fun AutomaticSync(
|
||||
if (state.linked && enabled) SyncSchedule.enable(context) else SyncSchedule.disable(context)
|
||||
}
|
||||
|
||||
var wanted by remember { mutableStateOf(false) }
|
||||
ForegroundTransitions(
|
||||
onForeground = { wanted = true },
|
||||
// Ready once `loading` is done, so the decision waits for the stored link to be
|
||||
// READ. At first composition `linked` is still false because nothing has looked in
|
||||
// the database yet, and acting on that would skip the sync on every cold start.
|
||||
OnEachForeground(
|
||||
ready = !state.loading,
|
||||
onBackground = {
|
||||
// Unsent work follows the person out of the app. Without this, a note
|
||||
// written on a phone that then goes into a pocket for the night does
|
||||
// not reach the desktop until the app is opened again by hand.
|
||||
if (enabled && state.linked && state.pending) SyncSchedule.pushSoon(context)
|
||||
},
|
||||
)
|
||||
|
||||
// Keyed on `loading` so the decision waits for the stored link to be READ. At
|
||||
// first composition `linked` is still false because nothing has looked in the
|
||||
// database yet, and acting on that would skip the sync on every cold start.
|
||||
LaunchedEffect(wanted, state.loading) {
|
||||
if (!wanted || state.loading) return@LaunchedEffect
|
||||
// Consumed here, so this fires exactly once per trip to the foreground
|
||||
// however many times the effect restarts. Writing a key from inside the
|
||||
// effect does restart it — but there is no suspension point between here
|
||||
// and the call below, so the block runs to completion before the
|
||||
// recomposition that would cancel it can be scheduled.
|
||||
wanted = false
|
||||
) {
|
||||
val worthIt = state.pending || olderThan(state.status?.lastSyncAt, STALE_MINUTES)
|
||||
if (enabled && state.linked && worthIt) onSync()
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package com.fabledsword.inkwell.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) }
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,12 @@ package com.fabledsword.inkwell.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
@@ -11,28 +15,31 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
/**
|
||||
* Calls back when the app comes to the front and when it leaves.
|
||||
*
|
||||
* `ON_START`/`ON_STOP` and not `ON_RESUME`/`ON_PAUSE`, which is the same choice
|
||||
* the editor's save-on-leave makes for the same reason: resume and pause fire for
|
||||
* `ON_START`/`ON_STOP` and not `ON_RESUME`/`ON_PAUSE`: resume and pause fire for
|
||||
* anything that merely covers the window — a permission dialog, the notification
|
||||
* shade — and a sync per shade-pull is not automatic sync, it is a stutter.
|
||||
* shade — and a sync per shade-pull is not automatic sync, it is a stutter, just as
|
||||
* a save per dialog would save the editor mid-sentence.
|
||||
*
|
||||
* A single-Activity app, so the Activity's lifecycle is the app's. If a second
|
||||
* Activity is ever added this needs `ProcessLifecycleOwner` instead, or rotating
|
||||
* between them will read as leaving and returning.
|
||||
*
|
||||
* Two callers, wanting opposite halves of it: automatic sync uses the return to
|
||||
* decide whether to fetch, and the reminder notice uses it to re-read a
|
||||
* permission the person may have just changed in the system settings.
|
||||
* Callers want different halves of it: automatic sync uses both, the reminder
|
||||
* notice and the update card use the return to re-read a permission the person may
|
||||
* have just changed in the system settings, and the editor uses the leaving to
|
||||
* write down text that otherwise exists only in a composable — the process can be
|
||||
* killed while backgrounded without it being told again.
|
||||
*
|
||||
* Both callbacks go through [rememberUpdatedState]: the observer is registered
|
||||
* once, and without it the lambda would keep reading the first composition's
|
||||
* state forever — deciding whether to push unsent notes from a snapshot taken
|
||||
* before any note existed.
|
||||
* before any note existed, or saving the first composition's empty text over a
|
||||
* full note.
|
||||
*/
|
||||
@Composable
|
||||
fun ForegroundTransitions(
|
||||
onForeground: () -> Unit,
|
||||
onBackground: () -> Unit,
|
||||
onForeground: () -> Unit = {},
|
||||
onBackground: () -> Unit = {},
|
||||
) {
|
||||
val forward by rememberUpdatedState(onForeground)
|
||||
val away by rememberUpdatedState(onBackground)
|
||||
@@ -50,3 +57,29 @@ fun ForegroundTransitions(
|
||||
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run [act] once per trip to the front, as soon as [ready] holds.
|
||||
*
|
||||
* A trip that arrives before [ready] is held, not dropped: it runs when [ready]
|
||||
* turns true. [onBackground] is passed through to [ForegroundTransitions].
|
||||
*
|
||||
* The trip is consumed inside the effect, so [act] fires once however many times
|
||||
* the effect restarts. Writing a key from inside the effect does restart it — but
|
||||
* there is no suspension point between consuming it and the call, so the block
|
||||
* runs to completion before the recomposition that would cancel it is scheduled.
|
||||
*/
|
||||
@Composable
|
||||
fun OnEachForeground(
|
||||
ready: Boolean,
|
||||
onBackground: () -> Unit = {},
|
||||
act: () -> Unit,
|
||||
) {
|
||||
var wanted by remember { mutableStateOf(false) }
|
||||
ForegroundTransitions(onForeground = { wanted = true }, onBackground = onBackground)
|
||||
LaunchedEffect(wanted, ready) {
|
||||
if (!wanted || !ready) return@LaunchedEffect
|
||||
wanted = false
|
||||
act()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ fun NoteEditorScreen(
|
||||
BackHandler(onBack = leave)
|
||||
|
||||
// The system picker, for any type: a note can carry a PDF as readily as a photo.
|
||||
// Leaving for it stops this screen, so FlushOnStop has already saved the text by
|
||||
// Leaving for it stops this screen, so the flush on leaving has already saved the text by
|
||||
// the time the files come back.
|
||||
val pickFiles =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
|
||||
@@ -152,7 +152,7 @@ fun NoteEditorScreen(
|
||||
// Leaving the APP is not closing the editor, so the text has to be saved
|
||||
// without the screen being torn down. Losing a paragraph to an incoming call
|
||||
// is exactly the failure that makes someone stop trusting a notes app.
|
||||
FlushOnStop(flush)
|
||||
ForegroundTransitions(onBackground = flush)
|
||||
|
||||
// The sheet shape, kept. `windowInsetsPadding` both insets the card below the
|
||||
// status bar AND consumes that inset, so the bar inside adds no second gap of
|
||||
@@ -340,7 +340,7 @@ private fun EditorOverlays(
|
||||
* How long typing has to stop before the note is written.
|
||||
*
|
||||
* Long enough that a normal sentence is one write, short enough that nothing
|
||||
* meaningful is at risk if the app dies. The flush on close and [FlushOnStop] still
|
||||
* meaningful is at risk if the app dies. The flush on close and on leaving the app still
|
||||
* cover the window between the last keystroke and this elapsing.
|
||||
*/
|
||||
private const val AUTOSAVE_IDLE_MS = 1_000L
|
||||
|
||||
@@ -48,7 +48,6 @@ fun ReminderNotice() {
|
||||
canNotify = notificationsAllowed(context)
|
||||
canBeExact = exactAllowed(context)
|
||||
},
|
||||
onBackground = {},
|
||||
)
|
||||
|
||||
if (canNotify && canBeExact) return
|
||||
|
||||
@@ -53,7 +53,7 @@ fun UpdateCard(
|
||||
// be up after someone granted it and came back, and the offer below it would
|
||||
// look blocked when it is not (family idea #5103, practice 9).
|
||||
var canInstall by remember { mutableStateOf(AppUpdate.canInstall(context)) }
|
||||
ForegroundTransitions(onForeground = { canInstall = AppUpdate.canInstall(context) }, onBackground = {})
|
||||
ForegroundTransitions(onForeground = { canInstall = AppUpdate.canInstall(context) })
|
||||
|
||||
// The system answers an install through a BroadcastReceiver, which has no way
|
||||
// back into a view model. This is the seam.
|
||||
|
||||
Reference in New Issue
Block a user