Live-test fixes: CarPlay disfavor, widget location refresh, debug cheapest parity
1. CarPlay widget taps could never launch Maps — FuelBoard is not a CarPlay app, and Apple only lets a widget launch its own app in CarPlay when that app is CarPlay-enabled. Marked the widget as a disfavored CarPlay location (iOS 26+; no-op below): read-only in the car, no dead tap. Header comment updated to match reality. 2. Widgets now track the user's location: timeline refresh 15m -> 5m, and the app's location manager reloads all widget timelines after every significant move (250m filter, throttled to 60s), so the widget re-orders around the new position while driving instead of waiting for the next tick. 3. Debug 'Cheapest in range' no longer shows the ALERT prediction (alertsFuel + alert radius — a different pool by design). It now mirrors the app list exactly: the TOP-badge station for the selected fuel within the stationLimit radius (new DebugAppCheapest computed in ContentView, passed to Settings). Alert-prediction cheapest kept as fallback when the app view hasn't resolved.
This commit is contained in:
@@ -67,6 +67,24 @@ struct ContentView: View {
|
|||||||
return displayedStations.first { $0.prices[selectedFuel] == baselinePrice }?.id
|
return displayedStations.first { $0.prices[selectedFuel] == baselinePrice }?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The app's own "cheapest in range" — the TOP-badge station — mirrored
|
||||||
|
/// into the Settings → Debug section so its "Cheapest in range" row
|
||||||
|
/// matches the app list (and the widget when the widget's fuel + distance
|
||||||
|
/// match the app's). The debug section previously showed the ALERT
|
||||||
|
/// prediction (alertsFuel + alert radius), which is a different pool by
|
||||||
|
/// design — that mismatch is what this fixes.
|
||||||
|
private var appCheapestStatus: DebugAppCheapest? {
|
||||||
|
guard let topID = topStationID,
|
||||||
|
let station = stations.first(where: { $0.id == topID }),
|
||||||
|
let location else { return nil }
|
||||||
|
return DebugAppCheapest(
|
||||||
|
station: station,
|
||||||
|
fuel: selectedFuel,
|
||||||
|
radiusKM: distanceUnit.toKM(Double(stationLimit)),
|
||||||
|
distanceKM: station.distanceKM(to: location.lat, lng2: location.lng)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private var displayedStations: [FuelStation] {
|
private var displayedStations: [FuelStation] {
|
||||||
sortedStations
|
sortedStations
|
||||||
}
|
}
|
||||||
@@ -258,7 +276,8 @@ struct ContentView: View {
|
|||||||
onPlainTestAlert: { monitor.sendPlainTestNotification() },
|
onPlainTestAlert: { monitor.sendPlainTestNotification() },
|
||||||
onRefreshDebugStatus: { monitor.refreshDebugStatus() },
|
onRefreshDebugStatus: { monitor.refreshDebugStatus() },
|
||||||
onShowOnboarding: { showOnboarding = true },
|
onShowOnboarding: { showOnboarding = true },
|
||||||
debugStatus: monitor.debugStatus
|
debugStatus: monitor.debugStatus,
|
||||||
|
appCheapest: appCheapestStatus
|
||||||
)
|
)
|
||||||
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
|
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
|
||||||
}
|
}
|
||||||
@@ -494,7 +513,18 @@ final class LocationManager: NSObject, ObservableObject, @preconcurrency CLLocat
|
|||||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||||
guard let loc = locations.last else { return }
|
guard let loc = locations.last else { return }
|
||||||
current = Coordinate(lat: loc.coordinate.latitude, lng: loc.coordinate.longitude)
|
current = Coordinate(lat: loc.coordinate.latitude, lng: loc.coordinate.longitude)
|
||||||
|
// Drive the widget: every significant move re-orders the widget list
|
||||||
|
// around the new position immediately, instead of waiting for the
|
||||||
|
// widget's own 5-minute timeline tick. Throttled — WidgetKit ignores
|
||||||
|
// reload spam and the relay has a rate limit, so 60 s is plenty.
|
||||||
|
let now = Date()
|
||||||
|
if now.timeIntervalSince(lastWidgetReload) >= 60 {
|
||||||
|
lastWidgetReload = now
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lastWidgetReload = Date.distantPast
|
||||||
|
|
||||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||||
// Ignore — the app still works price-sorted without location.
|
// Ignore — the app still works price-sorted without location.
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ struct DebugLocationStatus: Equatable {
|
|||||||
var cheapestDistanceKM: Double?
|
var cheapestDistanceKM: Double?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// App-view cheapest for the Debug section: mirrors EXACTLY what the Stations
|
||||||
|
/// list computes (selected fuel, stationLimit radius, current location), so
|
||||||
|
/// the debug "Cheapest in range" row matches the TOP badge in the app —
|
||||||
|
/// not the alert prediction (which uses alertsFuel + alert radius, a
|
||||||
|
/// different pool by design).
|
||||||
|
struct DebugAppCheapest: Equatable {
|
||||||
|
let station: FuelStation
|
||||||
|
let fuel: FuelType
|
||||||
|
let radiusKM: Double
|
||||||
|
let distanceKM: Double
|
||||||
|
}
|
||||||
|
|
||||||
/// A station the user wants to see on a map — set when a notification tap
|
/// A station the user wants to see on a map — set when a notification tap
|
||||||
/// can't hand off to Apple Maps (e.g. inside LiveContainer), so the app shows
|
/// can't hand off to Apple Maps (e.g. inside LiveContainer), so the app shows
|
||||||
/// an in-app map with directions instead.
|
/// an in-app map with directions instead.
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ struct SettingsView: View {
|
|||||||
var onShowOnboarding: () -> Void = {}
|
var onShowOnboarding: () -> Void = {}
|
||||||
/// Live snapshot of the device fix + stations in range (from the monitor).
|
/// Live snapshot of the device fix + stations in range (from the monitor).
|
||||||
var debugStatus: DebugLocationStatus?
|
var debugStatus: DebugLocationStatus?
|
||||||
|
/// The app's own TOP-badge cheapest (selected fuel + stationLimit radius),
|
||||||
|
/// so the debug "Cheapest in range" row matches the app list exactly.
|
||||||
|
var appCheapest: DebugAppCheapest?
|
||||||
|
|
||||||
@StateObject private var tipStore = TipStore()
|
@StateObject private var tipStore = TipStore()
|
||||||
@State private var showTipAlert = false
|
@State private var showTipAlert = false
|
||||||
@@ -96,7 +99,7 @@ struct SettingsView: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Debug")
|
Text("Debug")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("The first button applies the real criteria — cheapest \(alertsFuel.displayName.lowercased()) station within \(distanceUnit.format(alertsRadiusKM)) of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (nearest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions.")
|
Text("The first button applies the real criteria — cheapest \(alertsFuel.displayName.lowercased()) station within \(distanceUnit.format(alertsRadiusKM)) of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (nearest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius.")
|
||||||
}
|
}
|
||||||
.onAppear { onRefreshDebugStatus() }
|
.onAppear { onRefreshDebugStatus() }
|
||||||
}
|
}
|
||||||
@@ -180,8 +183,9 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Debug location block: device coordinates + fix age, then the in-range
|
/// Debug location block: device coordinates + fix age, then the in-range
|
||||||
/// indicator. "In range" mirrors the live alert criteria — stations selling
|
/// indicator. "Cheapest in range" mirrors the app list (selected fuel +
|
||||||
/// the monitored fuel within the alert radius of the last fix.
|
/// stationLimit radius) via `appCheapest`; the "In range" count mirrors
|
||||||
|
/// the alert prediction (alert fuel + alert radius).
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var debugLocationRows: some View {
|
private var debugLocationRows: some View {
|
||||||
Divider()
|
Divider()
|
||||||
@@ -226,7 +230,18 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let cheapest = status.cheapestInRange {
|
if let cheapest = appCheapest {
|
||||||
|
HStack {
|
||||||
|
Label("Cheapest in range", systemImage: "fuelpump.fill")
|
||||||
|
Spacer()
|
||||||
|
Text(cheapestText(cheapest.station, fuel: cheapest.fuel, distance: cheapest.distanceKM))
|
||||||
|
.font(.footnote)
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else if let cheapest = status.cheapestInRange {
|
||||||
|
// Fallback when the app view hasn't resolved yet — show the
|
||||||
|
// alert-prediction cheapest rather than nothing.
|
||||||
HStack {
|
HStack {
|
||||||
Label("Cheapest in range", systemImage: "fuelpump.fill")
|
Label("Cheapest in range", systemImage: "fuelpump.fill")
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|||||||
@@ -5,13 +5,18 @@ 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 (maps://?daddr=). The widget uses
|
// Taps deep-link to Apple Maps directions (maps://?daddr=). On the Home
|
||||||
// the native Maps scheme (not a custom app scheme) so that CarPlay can route
|
// Screen the system either opens Maps directly or delivers the URL to
|
||||||
// the tap straight to Apple Maps — which IS a CarPlay app — without needing
|
// FuelBoard, whose onOpenURL forwards it (and also still handles legacy
|
||||||
// the containing app to launch. On the Home Screen the system either opens
|
// fuelboard:// and http://maps.apple.com links from older cached timelines).
|
||||||
// Maps directly or delivers the URL to FuelBoard, whose onOpenURL forwards it
|
//
|
||||||
// (and also still handles legacy fuelboard:// and http://maps.apple.com links
|
// CarPlay: this widget is marked as a DISFAVORED location there. Widgets in
|
||||||
// from older cached timelines).
|
// CarPlay can only launch their OWN app, and only when that app is itself a
|
||||||
|
// CarPlay app (fueling entitlement). FuelBoard is not a CarPlay app, so a
|
||||||
|
// tap in the car would be a dead interaction — Apple's guidance for widgets
|
||||||
|
// whose purpose is launching a non-CarPlay app is to disfavor CarPlay: the
|
||||||
|
// widget stays visible (read-only prices) but interaction is disabled, so
|
||||||
|
// there is no tap that silently does nothing.
|
||||||
//
|
//
|
||||||
// Each widget instance is configured INDEPENDENTLY via its own App Intent
|
// Each widget instance is configured INDEPENDENTLY via its own App Intent
|
||||||
// (long-press → Edit Widget): fuel type (Unleaded/Premium/Diesel) and sort
|
// (long-press → Edit Widget): fuel type (Unleaded/Premium/Diesel) and sort
|
||||||
@@ -30,6 +35,24 @@ struct FuelPriceWidget: Widget {
|
|||||||
let kind = "FuelPriceWidget"
|
let kind = "FuelPriceWidget"
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
var body: some WidgetConfiguration {
|
||||||
|
// CarPlay: mark as disfavored — FuelBoard is not a CarPlay app, so
|
||||||
|
// widget taps there can never launch Maps (Apple only allows a widget
|
||||||
|
// to launch its own app in CarPlay, and only CarPlay-enabled apps).
|
||||||
|
// Disfavored = read-only in the car, no dead interaction. The
|
||||||
|
// locations array is empty below iOS 26 where `.carPlay` doesn't
|
||||||
|
// exist, making the modifier a harmless no-op there.
|
||||||
|
baseConfiguration()
|
||||||
|
.disfavoredLocations(carPlayLocations, for: [.systemSmall])
|
||||||
|
}
|
||||||
|
|
||||||
|
private var carPlayLocations: [WidgetLocation] {
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
return [.carPlay]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func baseConfiguration() -> some WidgetConfiguration {
|
||||||
AppIntentConfiguration(
|
AppIntentConfiguration(
|
||||||
kind: kind,
|
kind: kind,
|
||||||
intent: FuelBoardWidgetConfigurationIntent.self,
|
intent: FuelBoardWidgetConfigurationIntent.self,
|
||||||
@@ -79,7 +102,9 @@ struct FuelPriceTimelineProvider: AppIntentTimelineProvider {
|
|||||||
in context: Context
|
in context: Context
|
||||||
) async -> Timeline<FuelPriceEntry> {
|
) async -> Timeline<FuelPriceEntry> {
|
||||||
let entry = await makeEntry(configuration: configuration)
|
let entry = await makeEntry(configuration: configuration)
|
||||||
let nextRefresh = Calendar.current.date(byAdding: .minute, value: 15, to: Date())!
|
// Short cadence so the widget re-orders around a new location while
|
||||||
|
// driving — the provider re-fetches a fresh fix + prices each tick.
|
||||||
|
let nextRefresh = Calendar.current.date(byAdding: .minute, value: 5, to: Date())!
|
||||||
return Timeline(entries: [entry], policy: .after(nextRefresh))
|
return Timeline(entries: [entry], policy: .after(nextRefresh))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user