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()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
ForegroundTransitions(onForeground = { refresh() }, onBackground = {})
|
ForegroundTransitions(onForeground = { refresh() })
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
// TIRAMISU is where POST_NOTIFICATIONS became a runtime permission. Below
|
// TIRAMISU is where POST_NOTIFICATIONS became a runtime permission. Below
|
||||||
@@ -461,17 +461,9 @@ private fun AutomaticUpdate(
|
|||||||
linked: Boolean,
|
linked: Boolean,
|
||||||
onCheck: () -> Unit,
|
onCheck: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var wanted by remember { mutableStateOf(false) }
|
// Waits for a link rather than dropping the trip: linking a device while the app is
|
||||||
ForegroundTransitions(onForeground = { wanted = true }, onBackground = {})
|
// in front checks as soon as it is linked.
|
||||||
|
OnEachForeground(ready = linked, act = onCheck)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -508,28 +500,18 @@ private fun AutomaticSync(
|
|||||||
if (state.linked && enabled) SyncSchedule.enable(context) else SyncSchedule.disable(context)
|
if (state.linked && enabled) SyncSchedule.enable(context) else SyncSchedule.disable(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
var wanted by remember { mutableStateOf(false) }
|
// Ready once `loading` is done, so the decision waits for the stored link to be
|
||||||
ForegroundTransitions(
|
// READ. At first composition `linked` is still false because nothing has looked in
|
||||||
onForeground = { wanted = true },
|
// the database yet, and acting on that would skip the sync on every cold start.
|
||||||
|
OnEachForeground(
|
||||||
|
ready = !state.loading,
|
||||||
onBackground = {
|
onBackground = {
|
||||||
// Unsent work follows the person out of the app. Without this, a note
|
// 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
|
// 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.
|
// not reach the desktop until the app is opened again by hand.
|
||||||
if (enabled && state.linked && state.pending) SyncSchedule.pushSoon(context)
|
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)
|
val worthIt = state.pending || olderThan(state.status?.lastSyncAt, STALE_MINUTES)
|
||||||
if (enabled && state.linked && worthIt) onSync()
|
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.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
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.
|
* 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
|
* `ON_START`/`ON_STOP` and not `ON_RESUME`/`ON_PAUSE`: resume and pause fire for
|
||||||
* the editor's save-on-leave makes for the same reason: resume and pause fire for
|
|
||||||
* anything that merely covers the window — a permission dialog, the notification
|
* 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
|
* 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
|
* Activity is ever added this needs `ProcessLifecycleOwner` instead, or rotating
|
||||||
* between them will read as leaving and returning.
|
* between them will read as leaving and returning.
|
||||||
*
|
*
|
||||||
* Two callers, wanting opposite halves of it: automatic sync uses the return to
|
* Callers want different halves of it: automatic sync uses both, the reminder
|
||||||
* decide whether to fetch, and the reminder notice uses it to re-read a
|
* notice and the update card use the return to re-read a permission the person may
|
||||||
* permission the person may have just changed in the system settings.
|
* 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
|
* Both callbacks go through [rememberUpdatedState]: the observer is registered
|
||||||
* once, and without it the lambda would keep reading the first composition's
|
* 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
|
* 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
|
@Composable
|
||||||
fun ForegroundTransitions(
|
fun ForegroundTransitions(
|
||||||
onForeground: () -> Unit,
|
onForeground: () -> Unit = {},
|
||||||
onBackground: () -> Unit,
|
onBackground: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val forward by rememberUpdatedState(onForeground)
|
val forward by rememberUpdatedState(onForeground)
|
||||||
val away by rememberUpdatedState(onBackground)
|
val away by rememberUpdatedState(onBackground)
|
||||||
@@ -50,3 +57,29 @@ fun ForegroundTransitions(
|
|||||||
onDispose { owner.lifecycle.removeObserver(observer) }
|
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)
|
BackHandler(onBack = leave)
|
||||||
|
|
||||||
// The system picker, for any type: a note can carry a PDF as readily as a photo.
|
// 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.
|
// the time the files come back.
|
||||||
val pickFiles =
|
val pickFiles =
|
||||||
rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { uris ->
|
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
|
// 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
|
// 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.
|
// 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
|
// 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
|
// 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.
|
* How long typing has to stop before the note is written.
|
||||||
*
|
*
|
||||||
* Long enough that a normal sentence is one write, short enough that nothing
|
* 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.
|
* cover the window between the last keystroke and this elapsing.
|
||||||
*/
|
*/
|
||||||
private const val AUTOSAVE_IDLE_MS = 1_000L
|
private const val AUTOSAVE_IDLE_MS = 1_000L
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ fun ReminderNotice() {
|
|||||||
canNotify = notificationsAllowed(context)
|
canNotify = notificationsAllowed(context)
|
||||||
canBeExact = exactAllowed(context)
|
canBeExact = exactAllowed(context)
|
||||||
},
|
},
|
||||||
onBackground = {},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (canNotify && canBeExact) return
|
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
|
// 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).
|
// look blocked when it is not (family idea #5103, practice 9).
|
||||||
var canInstall by remember { mutableStateOf(AppUpdate.canInstall(context)) }
|
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
|
// The system answers an install through a BroadcastReceiver, which has no way
|
||||||
// back into a view model. This is the seam.
|
// back into a view model. This is the seam.
|
||||||
|
|||||||
Reference in New Issue
Block a user