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:
2 files changed
+54
-5
No files matched your search
@@ -244,7 +244,11 @@ fun BlockerScreen(
|
|||||||
"stop enforcing."
|
"stop enforcing."
|
||||||
},
|
},
|
||||||
checked = ui.enabled,
|
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)) },
|
onChange = { value -> patch(mapOf("enabled" to value)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -398,7 +402,11 @@ fun BlockerScreen(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
PrimaryButton(text = "Save lists", enabled = dirty, onClick = ::saveLists)
|
PrimaryButton(
|
||||||
|
text = "Save lists",
|
||||||
|
enabled = dirty && ui.connection == Connection.Online,
|
||||||
|
onClick = ::saveLists,
|
||||||
|
)
|
||||||
GhostButton(
|
GhostButton(
|
||||||
text = "Discard",
|
text = "Discard",
|
||||||
enabled = dirty,
|
enabled = dirty,
|
||||||
|
|||||||
@@ -68,6 +68,32 @@ fun OnboardingScreen(
|
|||||||
var problem by remember { mutableStateOf<String?>(null) }
|
var problem by remember { mutableStateOf<String?>(null) }
|
||||||
var reassurance 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() {
|
fun connect() {
|
||||||
val normalized = KadencePrefs.normalizeBaseUrl(url)
|
val normalized = KadencePrefs.normalizeBaseUrl(url)
|
||||||
if (normalized.isBlank()) {
|
if (normalized.isBlank()) {
|
||||||
@@ -78,6 +104,7 @@ fun OnboardingScreen(
|
|||||||
checking = true
|
checking = true
|
||||||
problem = null
|
problem = null
|
||||||
reassurance = null
|
reassurance = null
|
||||||
|
unreachable = false
|
||||||
|
|
||||||
// Saved first, because the repository reads the live config — there is no way
|
// 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
|
// 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.Ok -> Unit
|
||||||
is ApiResult.NetworkError -> {
|
is ApiResult.NetworkError -> {
|
||||||
checking = false
|
checking = false
|
||||||
|
unreachable = true
|
||||||
problem = "Nothing answered at $normalized.\n\n" +
|
problem = "Nothing answered at $normalized.\n\n" +
|
||||||
"If the backend is on your LAN, check that an always-on VPN " +
|
"If the backend is on your LAN, check that an always-on VPN " +
|
||||||
"is not swallowing the address."
|
"is not swallowing the address."
|
||||||
@@ -252,9 +280,22 @@ fun OnboardingScreen(
|
|||||||
Spacer(Modifier.fillMaxWidth(0.1f))
|
Spacer(Modifier.fillMaxWidth(0.1f))
|
||||||
}
|
}
|
||||||
|
|
||||||
// There is no "skip": without a backend every screen is empty, and an app
|
// No unconditional "skip": a first run with a *wrong* address is better
|
||||||
// that lets you past this point only to show nothing is worse than one that
|
// stopped here, where the message can explain it, than three screens deep. But
|
||||||
// insists. The escape hatch is that a wrong answer is editable in Settings.
|
// 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) {
|
if (config.onboarded) {
|
||||||
GhostButton(text = "Back", onClick = onDone)
|
GhostButton(text = "Back", onClick = onDone)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user