Widget opens Maps via maps://; onboarding prompts after Continue; forced first fetch

- Widget tap-to-directions now uses the native maps:// scheme instead of
  http://maps.apple.com. The universal-link http URL failed to open from a
  widget and fell back to launching the containing app; maps:// opens
  Apple Maps directly for both small (whole widget) and medium (per row).
- Onboarding: removed the Skip button (mandatory flow), and system prompts
  (location, notifications, local network) now fire only when the user taps
  Continue/Allow on each page — not when the page appears. Pages
  auto-advance once a permission is granted.
- After 'Start Using FuelBoard' the first data fetch is forced, so a
  reinstall that leaves a recent lastRefresh in app-group defaults can no
  longer skip the fetch and leave the station list empty. 35 tests pass.
This commit is contained in:
FuelBoard Contributor
2026-08-12 12:41:19 +01:00
parent 2a8de27571
commit 9ce0c83d66
4 changed files with 38 additions and 26 deletions
+6 -3
View File
@@ -182,13 +182,16 @@ struct ContentView: View {
.onChange(of: showOnboarding) { _, showing in
// After onboarding finishes (or the test re-run is dismissed),
// begin foreground location tracking if permission allows, and
// run the first data fetch (the relay probe during onboarding
// already surfaced the Local Network prompt).
// run the FIRST data fetch. This must be a forced refresh: a
// reinstall may leave a recent `lastRefresh` in the app-group
// defaults (which survive app deletion), which would make the
// cache-gated refresh skip the fetch and leave the station list
// empty on a brand-new install.
if !showing, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: alertsRadius)
Task { await refresh() }
Task { await refresh(force: true) }
}
}
.onChange(of: scenePhase) { _, newPhase in
+26 -20
View File
@@ -18,15 +18,8 @@ struct OnboardingView: View {
var body: some View {
VStack(spacing: 0) {
// Top bar: skip + page dots
// Top bar: page dots only onboarding is mandatory, no skip.
HStack {
if page < totalPages - 1 {
Button("Skip") { finish() }
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Color.clear.frame(width: 40, height: 20)
}
Spacer()
HStack(spacing: 8) {
ForEach(0..<totalPages, id: \.self) { index in
@@ -36,7 +29,6 @@ struct OnboardingView: View {
}
}
Spacer()
Color.clear.frame(width: 40, height: 20) // balances Skip
}
.padding(.horizontal, 20)
.padding(.top, 12)
@@ -49,17 +41,23 @@ struct OnboardingView: View {
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
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
// Auto-advance once the user grants a permission the prompt itself
// only fires when the Continue button is tapped (after reading the
// page's description), never when the page merely appears.
.onChange(of: prompter.locationGranted) { _, granted in
if granted, page == 1 { page = 2 }
}
.onChange(of: prompter.notificationsGranted) { _, granted in
if granted, page == 2 { page = 3 }
}
.onChange(of: prompter.dataGranted) { _, granted in
if granted, page == 3 { page = 4 }
}
.background(
LinearGradient(
colors: [Color(.systemBackground), Color.accentColor.opacity(0.06)],
@@ -263,9 +261,12 @@ struct OnboardingView: View {
) {
if prompter.locationDenied {
openSettings()
} else {
} else if !prompter.locationGranted {
// The system prompt fires HERE after the user has read
// the description and tapped Continue not on page appear.
prompter.requestLocation()
if prompter.locationGranted { page = 2 }
} else {
page = 2
}
}
case 2:
@@ -274,9 +275,10 @@ struct OnboardingView: View {
) {
if prompter.notificationsDenied {
page = 3
} else {
} else if !prompter.notificationsGranted {
prompter.requestNotifications()
if prompter.notificationsGranted { page = 3 }
} else {
page = 3
}
}
case 3:
@@ -286,8 +288,12 @@ struct OnboardingView: View {
) {
if prompter.dataDenied {
openSettings()
} else if prompter.dataGranted || !prompter.dataLoading {
} else if prompter.dataGranted {
page = 4
} else if !prompter.dataLoading {
// Fires the Local Network prompt + relay probe here,
// after the user has read the page and tapped Continue.
prompter.requestDataAccess()
}
}
.disabled(prompter.dataLoading)
+2 -2
View File
@@ -5,7 +5,7 @@ import AppIntents
// FuelBoard widget petrol stations near you.
// systemMedium: top 3-4 stations with price + distance, each row opens Maps
// systemSmall: single station, whole widget opens Maps
// Taps deep-link to Apple Maps directions (http://maps.apple.com/?daddr=).
// Taps deep-link to Apple Maps directions (maps://?daddr=).
// Note: on the home screen Link opens Maps directly. Inside CarPlay the widget
// renders but can't launch Maps (widgets can only launch their own CarPlay app).
//
@@ -261,7 +261,7 @@ struct FuelPriceWidgetView: View {
.foregroundStyle(.secondary)
}
ForEach(entry.stations.prefix(3)) { station in
Link(destination: station.mapsDirectionsURL ?? URL(string: "http://maps.apple.com")!) {
Link(destination: station.mapsDirectionsURL ?? URL(string: "maps://")!) {
HStack(spacing: 8) {
Text(station.brand.sanitizedStationTitle)
.font(.caption.weight(.semibold))
+4 -1
View File
@@ -154,8 +154,11 @@ struct FuelStation: Identifiable, Codable, Equatable {
}
/// Apple Maps directions URL used by the widget tap and app rows.
/// Uses the native `maps://` scheme (not `http://maps.apple.com`), which
/// opens Maps directly; universal-link http URLs fall back to opening the
/// containing app when tapped from a widget.
var mapsDirectionsURL: URL? {
URL(string: "http://maps.apple.com/?daddr=\(lat),\(lng)&t=d")
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")
}
/// Name of the bundled brand logo asset, or nil if unknown.