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:
@@ -182,13 +182,16 @@ struct ContentView: View {
|
|||||||
.onChange(of: showOnboarding) { _, showing in
|
.onChange(of: showOnboarding) { _, showing in
|
||||||
// After onboarding finishes (or the test re-run is dismissed),
|
// After onboarding finishes (or the test re-run is dismissed),
|
||||||
// begin foreground location tracking if permission allows, and
|
// begin foreground location tracking if permission allows, and
|
||||||
// run the first data fetch (the relay probe during onboarding
|
// run the FIRST data fetch. This must be a forced refresh: a
|
||||||
// already surfaced the Local Network prompt).
|
// 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() {
|
if !showing, FuelStore.loadHasCompletedOnboarding() {
|
||||||
locationManager.startForegroundTracking()
|
locationManager.startForegroundTracking()
|
||||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||||
fuel: alertsFuel, radiusKM: alertsRadius)
|
fuel: alertsFuel, radiusKM: alertsRadius)
|
||||||
Task { await refresh() }
|
Task { await refresh(force: true) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { _, newPhase in
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
|
|||||||
@@ -18,15 +18,8 @@ struct OnboardingView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Top bar: skip + page dots
|
// Top bar: page dots only — onboarding is mandatory, no skip.
|
||||||
HStack {
|
HStack {
|
||||||
if page < totalPages - 1 {
|
|
||||||
Button("Skip") { finish() }
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
} else {
|
|
||||||
Color.clear.frame(width: 40, height: 20)
|
|
||||||
}
|
|
||||||
Spacer()
|
Spacer()
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(0..<totalPages, id: \.self) { index in
|
ForEach(0..<totalPages, id: \.self) { index in
|
||||||
@@ -36,7 +29,6 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Color.clear.frame(width: 40, height: 20) // balances Skip
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
@@ -49,17 +41,23 @@ struct OnboardingView: View {
|
|||||||
donePage.tag(4)
|
donePage.tag(4)
|
||||||
}
|
}
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
.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
|
bottomAction
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.bottom, 24)
|
.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(
|
.background(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [Color(.systemBackground), Color.accentColor.opacity(0.06)],
|
colors: [Color(.systemBackground), Color.accentColor.opacity(0.06)],
|
||||||
@@ -263,9 +261,12 @@ struct OnboardingView: View {
|
|||||||
) {
|
) {
|
||||||
if prompter.locationDenied {
|
if prompter.locationDenied {
|
||||||
openSettings()
|
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()
|
prompter.requestLocation()
|
||||||
if prompter.locationGranted { page = 2 }
|
} else {
|
||||||
|
page = 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 2:
|
case 2:
|
||||||
@@ -274,9 +275,10 @@ struct OnboardingView: View {
|
|||||||
) {
|
) {
|
||||||
if prompter.notificationsDenied {
|
if prompter.notificationsDenied {
|
||||||
page = 3
|
page = 3
|
||||||
} else {
|
} else if !prompter.notificationsGranted {
|
||||||
prompter.requestNotifications()
|
prompter.requestNotifications()
|
||||||
if prompter.notificationsGranted { page = 3 }
|
} else {
|
||||||
|
page = 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 3:
|
case 3:
|
||||||
@@ -286,8 +288,12 @@ struct OnboardingView: View {
|
|||||||
) {
|
) {
|
||||||
if prompter.dataDenied {
|
if prompter.dataDenied {
|
||||||
openSettings()
|
openSettings()
|
||||||
} else if prompter.dataGranted || !prompter.dataLoading {
|
} else if prompter.dataGranted {
|
||||||
page = 4
|
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)
|
.disabled(prompter.dataLoading)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import AppIntents
|
|||||||
// FuelBoard widget — petrol stations near you.
|
// FuelBoard widget — petrol stations near you.
|
||||||
// systemMedium: top 3-4 stations with price + distance, each row opens Maps
|
// systemMedium: top 3-4 stations with price + distance, each row opens Maps
|
||||||
// systemSmall: single station, whole widget 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
|
// 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).
|
// renders but can't launch Maps (widgets can only launch their own CarPlay app).
|
||||||
//
|
//
|
||||||
@@ -261,7 +261,7 @@ struct FuelPriceWidgetView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
ForEach(entry.stations.prefix(3)) { station in
|
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) {
|
HStack(spacing: 8) {
|
||||||
Text(station.brand.sanitizedStationTitle)
|
Text(station.brand.sanitizedStationTitle)
|
||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
|
|||||||
@@ -154,8 +154,11 @@ struct FuelStation: Identifiable, Codable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Apple Maps directions URL — used by the widget tap and app rows.
|
/// 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? {
|
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.
|
/// Name of the bundled brand logo asset, or nil if unknown.
|
||||||
|
|||||||
Reference in New Issue
Block a user