Onboarding: silent Local Network pre-flight on replay

Replaying onboarding showed a visible probe on the 4th page because
iOS exposes no status API for the Local Network permission — the
connection attempt IS the only detector. Location and Notifications
query their status at init, so their ticks are already known when the
page appears; Local Network could only be known by probing.

Now, when onboarding is re-opened (already completed), a pre-flight
probe runs silently at open: granted connects in ms, denied reports
.waiting/.localNetworkDenied immediately, and a plain .waiting
(permission undetermined after a reinstall) cancels so page 4's
Continue still fires the prompt in context. By the time the data page
appears its tick (or denied text) is already on screen, matching the
other permission pages. First run is unchanged.
This commit is contained in:
FuelBoard Contributor
2026-08-13 10:29:20 +01:00
parent ed243fae9e
commit a1fa0f9998
+72
View File
@@ -69,6 +69,18 @@ struct OnboardingView: View {
} }
} }
} }
// Replay path (test button / returning user): the Local Network
// permission is already decided, so probe silently up front. The
// probe fires no prompt once the permission is granted or denied
// only the FIRST attempt (undetermined) shows the system alert, and
// that only happens on first run. By the time the data page appears
// its status is known, so it shows the green tick (or denied text)
// immediately, matching the Location and Notifications pages.
.onAppear {
if FuelStore.loadHasCompletedOnboarding() {
prompter.preflightDataAccess()
}
}
.background( .background(
LinearGradient( LinearGradient(
colors: [Color(.systemBackground), Color.accentColor.opacity(0.06)], colors: [Color(.systemBackground), Color.accentColor.opacity(0.06)],
@@ -482,6 +494,66 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur
} }
} }
/// Replay pre-flight: determines the Local Network status WITHOUT any
/// user-visible check. Once the permission is decided (granted or denied)
/// a probe resolves silently granted connects in milliseconds, denied
/// reports `.waiting` with `unsatisfiedReason == .localNetworkDenied`.
/// Only the FIRST-ever attempt (undetermined) shows the system alert; in
/// that case we cancel and leave the state unknown so the data page's
/// Continue triggers the prompt in context. First run never calls this.
func preflightDataAccess() {
guard !dataLoading, !dataGranted, !dataDenied else { return }
dataLoading = true
let baseURL = RelayFuelProvider().baseURL
guard let host = baseURL.host,
let port = baseURL.port else {
dataLoading = false
return
}
let connection = NWConnection(
host: NWEndpoint.Host(host),
port: NWEndpoint.Port(rawValue: UInt16(port))!,
using: .tcp
)
dataConnection = connection
connection.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
guard let self else { return }
switch state {
case .ready:
self.dataConnection = nil
self.dataLoading = false
self.dataGranted = true
self.dataDenied = false
case .waiting:
if connection.currentPath?.unsatisfiedReason == .localNetworkDenied {
// Denied in Settings resolved silently, no prompt.
self.dataConnection = nil
self.dataLoading = false
self.dataGranted = false
self.dataDenied = true
} else {
// Prompt pending (permission undetermined only
// possible after a reinstall, where the onboarding
// flag persisted but the permission reset). Cancel so
// page 4's Continue fires the prompt in context.
self.dataConnection = nil
self.dataLoading = false
connection.cancel()
}
case .failed, .cancelled:
self.dataConnection = nil
self.dataLoading = false
self.dataGranted = false
self.dataDenied = true
default:
break // .preparing probe starting, hold on
}
}
}
connection.start(queue: .main)
}
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
Task { @MainActor in Task { @MainActor in
self.refreshLocationStatus() self.refreshLocationStatus()