diff --git a/Config/App-Info.plist b/Config/App-Info.plist
index 893d1b5..a5269bf 100644
--- a/Config/App-Info.plist
+++ b/Config/App-Info.plist
@@ -26,6 +26,8 @@
FuelBoard uses your location to find the cheapest nearby petrol stations.
NSLocationAlwaysAndWhenInUseUsageDescription
FuelBoard uses Always location to alert you when you approach the cheapest station nearby.
+ NSLocalNetworkUsageDescription
+ FuelBoard connects to the FuelBoard Relay on your local network to download the latest fuel prices.
UIBackgroundModes
location
diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift
index 6d157a5..9dd84d5 100644
--- a/FuelBoard/ContentView.swift
+++ b/FuelBoard/ContentView.swift
@@ -165,28 +165,40 @@ struct ContentView: View {
}
.onAppear {
// Onboarding runs first on a fresh install — it owns the initial
- // permission prompts. Location tracking starts once it's done.
+ // permission prompts (location, notifications, and the data/local
+ // network probe on the Data page). Location tracking and the first
+ // network fetch start once it's done.
if FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking()
+ monitor.update(stations: stations, favourites: refreshedFavourites,
+ fuel: alertsFuel, radiusKM: alertsRadius)
+ monitor.setEnabled(alertsEnabled)
+ // Refresh only when the cache is stale (twice-a-day policy).
+ Task { await refresh() }
} else {
showOnboarding = true
}
- monitor.update(stations: stations, favourites: refreshedFavourites,
- fuel: alertsFuel, radiusKM: alertsRadius)
- monitor.setEnabled(alertsEnabled)
- // Refresh only when the cache is stale (twice-a-day policy).
- Task { await refresh() }
}
.onChange(of: showOnboarding) { _, showing in
// After onboarding finishes (or the test re-run is dismissed),
- // begin foreground location tracking if permission allows.
+ // begin foreground location tracking if permission allows, and
+ // run the first data fetch (the relay probe during onboarding
+ // already surfaced the Local Network prompt).
if !showing, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking()
+ monitor.update(stations: stations, favourites: refreshedFavourites,
+ fuel: alertsFuel, radiusKM: alertsRadius)
+ Task { await refresh() }
}
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
- locationManager.startForegroundTracking()
+ // Never start location tracking while onboarding is on screen —
+ // onboarding owns the initial permission prompts. Once it's
+ // completed, normal foreground tracking resumes.
+ if !showOnboarding, FuelStore.loadHasCompletedOnboarding() {
+ locationManager.startForegroundTracking()
+ }
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius)
// No network fetch on foreground — pull-to-refresh is the override.
diff --git a/FuelBoard/OnboardingView.swift b/FuelBoard/OnboardingView.swift
index e2dc867..4d6696e 100644
--- a/FuelBoard/OnboardingView.swift
+++ b/FuelBoard/OnboardingView.swift
@@ -3,10 +3,10 @@ import CoreLocation
import UserNotifications
/// First-launch onboarding: introduces FuelBoard, then walks the user through
-/// the two system permissions (location + notifications) with in-context
-/// prompts. In production it appears once at initial launch (driven by
-/// `FuelStore.loadHasCompletedOnboarding()`); a test button in the Alerts tab
-/// re-opens it anytime.
+/// the three system permissions (location, notifications, local network for
+/// data loading) with in-context prompts. In production it appears once at
+/// initial launch (driven by `FuelStore.loadHasCompletedOnboarding()`); a
+/// test button in the Alerts tab re-opens it anytime.
struct OnboardingView: View {
var onFinish: () -> Void
@@ -14,7 +14,7 @@ struct OnboardingView: View {
@StateObject private var prompter = OnboardingPermissionPrompter()
@State private var page = 0
- private let totalPages = 4
+ private let totalPages = 5
var body: some View {
VStack(spacing: 0) {
@@ -45,13 +45,15 @@ struct OnboardingView: View {
welcomePage.tag(0)
locationPage.tag(1)
notificationsPage.tag(2)
- donePage.tag(3)
+ dataPage.tag(3)
+ donePage.tag(4)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onChange(of: page) { _, newPage in
// Trigger each system prompt the moment its page appears.
if newPage == 1 { prompter.requestLocation() }
if newPage == 2 { prompter.requestNotifications() }
+ if newPage == 3 { prompter.requestDataAccess() }
}
bottomAction
@@ -168,6 +170,59 @@ struct OnboardingView: View {
}
}
+ private var dataPage: some View {
+ VStack(spacing: 0) {
+ Spacer()
+ ZStack {
+ Circle().fill(Color.teal.opacity(0.12)).frame(width: 96, height: 96)
+ Image(systemName: "arrow.down.circle.fill")
+ .font(.system(size: 40, weight: .semibold))
+ .foregroundStyle(.teal)
+ }
+ .padding(.bottom, 28)
+
+ Text("Prices, ready when you are")
+ .font(.largeTitle.bold())
+
+ Text("FuelBoard downloads the latest prices from a relay on your local network — the full UK dataset, refreshed twice a day. Local network access is needed for that first download.")
+ .font(.body)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 32)
+ .padding(.top, 12)
+
+ dataStatusLabel
+ .padding(.top, 24)
+
+ Spacer()
+ }
+ }
+
+ @ViewBuilder
+ private var dataStatusLabel: some View {
+ if prompter.dataLoading {
+ VStack(spacing: 10) {
+ ProgressView()
+ Text("Checking the FuelBoard Relay…")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ } else if prompter.dataGranted {
+ Label("Connected — prices will load", systemImage: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ } else if prompter.dataDenied {
+ Text("Local network access was denied — prices won't load until it's allowed. You can open Settings to change this.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 32)
+ } else {
+ Text("The system prompt will appear next.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+
private var donePage: some View {
VStack(spacing: 0) {
Spacer()
@@ -224,6 +279,24 @@ struct OnboardingView: View {
if prompter.notificationsGranted { page = 3 }
}
}
+ case 3:
+ VStack(spacing: 10) {
+ primaryButton(
+ prompter.dataDenied ? "Open Settings" : (prompter.dataGranted ? "Continue" : (prompter.dataLoading ? "Checking…" : "Continue"))
+ ) {
+ if prompter.dataDenied {
+ openSettings()
+ } else if prompter.dataGranted || !prompter.dataLoading {
+ page = 4
+ }
+ }
+ .disabled(prompter.dataLoading)
+ if prompter.dataDenied {
+ Button("Check again") { prompter.requestDataAccess() }
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
default:
primaryButton("Start Using FuelBoard") { finish() }
}
@@ -285,7 +358,7 @@ struct OnboardingView: View {
}
}
-/// Owns the two system permission requests during onboarding and publishes
+/// Owns the system permission requests during onboarding and publishes
/// their outcomes so the pages can reflect them live.
@MainActor
final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate {
@@ -293,6 +366,9 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur
@Published private(set) var locationDenied = false
@Published private(set) var notificationsGranted = false
@Published private(set) var notificationsDenied = false
+ @Published private(set) var dataGranted = false
+ @Published private(set) var dataDenied = false
+ @Published private(set) var dataLoading = false
private let manager = CLLocationManager()
@@ -334,6 +410,44 @@ final class OnboardingPermissionPrompter: NSObject, ObservableObject, @preconcur
}
}
+ /// Probes the FuelBoard Relay. The first connection to a local-network
+ /// address is what makes iOS show the Local Network permission prompt —
+ /// so this both triggers the prompt in context and verifies data will
+ /// actually load. A small request (limit 1) is enough; the full download
+ /// happens after onboarding completes.
+ func requestDataAccess() {
+ guard !dataLoading else { return }
+ dataLoading = true
+ dataDenied = false
+ Task {
+ var ok = false
+ defer {
+ dataLoading = false
+ dataGranted = ok
+ dataDenied = !ok
+ }
+ do {
+ var components = URLComponents(
+ url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"),
+ resolvingAgainstBaseURL: false
+ )!
+ components.queryItems = [
+ URLQueryItem(name: "fuel", value: FuelType.e10.rawValue),
+ URLQueryItem(name: "limit", value: "1"),
+ ]
+ var request = URLRequest(url: components.url!)
+ request.timeoutInterval = 8
+ let (_, response) = try await URLSession.shared.data(for: request)
+ guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
+ throw FuelProviderError.relayUnavailable
+ }
+ ok = true
+ } catch {
+ ok = false
+ }
+ }
+ }
+
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
Task { @MainActor in
self.refreshLocationStatus()