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) }