android: onboarding is not a wall, and server-owned edits say no offline

A never-connected install could not reach a single screen. Onboarding swapped the
whole shell out until `onboarded`, which only became true after `/api/health`
*and* `/api/timer/state` both answered, and there was no way past. The comment
explaining that argued an app which shows nothing is worse than one that insists
— which was true when nothing survived a process, and is not true now: the timer
ticks from the kept anchor, the guard enforces the kept rules, taps go to the
outbox and the lists show what they last held.

So once an attempt has actually failed *for want of a network* — not on a wrong
address, where stopping here is still right because this is where the message can
explain it — the screen offers "Use it offline for now" and says exactly what
that does and does not do.

The other direction, for honesty: the blocker's rules are the server's settings
document, and there is no outbox for it. A settings PUT is a deep merge of a
shared object, so queueing one would replay a stale version over whatever the web
UI did meanwhile. The "Focus guard on" toggle and "Save lists" are therefore
disabled offline instead of appearing to work and silently failing, which is the
pattern the Settings screen already followed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
timka123andClaude Opus 5 committed 2026-10-22 17:47:30 +03:00
1 parent a8c84c65da
commit 81032aca51
2 files changed
+54 -5

No files matched your search

@@ -244,7 +244,11 @@ fun BlockerScreen(
"stop enforcing."
},
checked = ui.enabled,
enabled = ui.connection != Connection.Unconfigured,
// The rules are the server's document and there is no outbox for it:
// a settings PUT is a deep merge of a shared object, and queueing one
// would mean replaying a stale version over whatever the web UI did
// meanwhile. So offline these say no, rather than appearing to work.
enabled = ui.connection == Connection.Online,
onChange = { value -> patch(mapOf("enabled" to value)) },
)
}
@@ -398,7 +402,11 @@ fun BlockerScreen(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PrimaryButton(text = "Save lists", enabled = dirty, onClick = ::saveLists)
PrimaryButton(
text = "Save lists",
enabled = dirty && ui.connection == Connection.Online,
onClick = ::saveLists,
)
GhostButton(
text = "Discard",
enabled = dirty,
@@ -68,6 +68,32 @@ fun OnboardingScreen(
var problem by remember { mutableStateOf<String?>(null) }
var reassurance by remember { mutableStateOf<String?>(null) }
/** Set once an attempt has failed for want of a network, which unlocks the way in. */
var unreachable by remember { mutableStateOf(false) }
/**
* Go in with the address saved but unconfirmed.
*
* The honest half of the old "there is no skip" argument was that an app which
* shows nothing is worse than one that insists. The dishonest half was that it
* *would* show nothing: the timer ticks from the kept anchor, the focus guard
* enforces the kept rules, taps go to the outbox, and the lists show what they
* last held. Keeping someone at this screen because their train is in a tunnel
* is the worse failure, so once an attempt has actually failed on the network
* this becomes available — and says plainly what it does and does not do.
*/
fun enterUnconfirmed() {
val normalized = KadencePrefs.normalizeBaseUrl(url)
onSave(
config.copy(
baseUrl = normalized,
apiToken = token.takeIf { it.isNotBlank() },
onboarded = true,
),
)
onDone()
}
fun connect() {
val normalized = KadencePrefs.normalizeBaseUrl(url)
if (normalized.isBlank()) {
@@ -78,6 +104,7 @@ fun OnboardingScreen(
checking = true
problem = null
reassurance = null
unreachable = false
// Saved first, because the repository reads the live config — there is no way
// to make a call "as if" configured. Onboarded stays false until it works, so
@@ -95,6 +122,7 @@ fun OnboardingScreen(
is ApiResult.Ok -> Unit
is ApiResult.NetworkError -> {
checking = false
unreachable = true
problem = "Nothing answered at $normalized.\n\n" +
"If the backend is on your LAN, check that an always-on VPN " +
"is not swallowing the address."
@@ -252,9 +280,22 @@ fun OnboardingScreen(
Spacer(Modifier.fillMaxWidth(0.1f))
}
// There is no "skip": without a backend every screen is empty, and an app
// that lets you past this point only to show nothing is worse than one that
// insists. The escape hatch is that a wrong answer is editable in Settings.
// No unconditional "skip": a first run with a *wrong* address is better
// stopped here, where the message can explain it, than three screens deep. But
// once the attempt has failed for want of a network, insisting is pointless —
// see [enterUnconfirmed].
if (unreachable) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
GhostButton(text = "Use it offline for now", onClick = ::enterUnconfirmed)
Text(
text = "The address is saved and nothing syncs until it answers. " +
"The timer, the focus guard and what you last loaded all work; " +
"anything you change is kept and sent when it is back.",
style = KadenceTheme.type.caption,
color = colors.inkMuted,
)
}
}
if (config.onboarded) {
GhostButton(text = "Back", onClick = onDone)
}