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:
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user