From 81032aca51a8bd7ff041b3c4c17b1188399cb0ce Mon Sep 17 00:00:00 2001 From: "Timur Kh." Date: Thu, 22 Oct 2026 17:47:30 +0300 Subject: [PATCH] android: onboarding is not a wall, and server-owned edits say no offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../android/ui/blocker/BlockerScreen.kt | 12 ++++- .../android/ui/onboarding/OnboardingScreen.kt | 47 +++++++++++++++++-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/blocker/BlockerScreen.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/blocker/BlockerScreen.kt index 0af805c..26ad238 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/ui/blocker/BlockerScreen.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/blocker/BlockerScreen.kt @@ -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, diff --git a/android/app/src/main/kotlin/tech/kadence/android/ui/onboarding/OnboardingScreen.kt b/android/app/src/main/kotlin/tech/kadence/android/ui/onboarding/OnboardingScreen.kt index 7aa76b3..b4084fd 100644 --- a/android/app/src/main/kotlin/tech/kadence/android/ui/onboarding/OnboardingScreen.kt +++ b/android/app/src/main/kotlin/tech/kadence/android/ui/onboarding/OnboardingScreen.kt @@ -68,6 +68,32 @@ fun OnboardingScreen( var problem by remember { mutableStateOf(null) } var reassurance by remember { mutableStateOf(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) }