From f822bd263682407de6c595dc0427cca29fca6666 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 12 Aug 2026 18:01:29 +0100 Subject: [PATCH] Live-test fixes: CarPlay disfavor, widget location refresh, debug cheapest parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- FuelBoard/ContentView.swift | 32 +++++++++++++++++++- FuelBoard/ProximityMonitor.swift | 12 ++++++++ FuelBoard/SettingsView.swift | 23 ++++++++++++--- FuelBoardWidgets/FuelPriceWidget.swift | 41 +++++++++++++++++++++----- 4 files changed, 95 insertions(+), 13 deletions(-) diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index a732b56..554102b 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -67,6 +67,24 @@ struct ContentView: View { 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] { sortedStations } @@ -258,7 +276,8 @@ struct ContentView: View { onPlainTestAlert: { monitor.sendPlainTestNotification() }, onRefreshDebugStatus: { monitor.refreshDebugStatus() }, onShowOnboarding: { showOnboarding = true }, - debugStatus: monitor.debugStatus + debugStatus: monitor.debugStatus, + appCheapest: appCheapestStatus ) .tabItem { Label("Settings", systemImage: "gearshape.fill") } } @@ -494,8 +513,19 @@ final class LocationManager: NSObject, ObservableObject, @preconcurrency CLLocat func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let loc = locations.last else { return } 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) { // Ignore — the app still works price-sorted without location. } diff --git a/FuelBoard/ProximityMonitor.swift b/FuelBoard/ProximityMonitor.swift index 1f7e8b1..c9ded30 100644 --- a/FuelBoard/ProximityMonitor.swift +++ b/FuelBoard/ProximityMonitor.swift @@ -18,6 +18,18 @@ struct DebugLocationStatus: Equatable { 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 /// can't hand off to Apple Maps (e.g. inside LiveContainer), so the app shows /// an in-app map with directions instead. diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index a4e1499..eb51f84 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -29,6 +29,9 @@ struct SettingsView: View { var onShowOnboarding: () -> Void = {} /// Live snapshot of the device fix + stations in range (from the monitor). 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() @State private var showTipAlert = false @@ -96,7 +99,7 @@ struct SettingsView: View { } header: { Text("Debug") } 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() } } @@ -180,8 +183,9 @@ struct SettingsView: View { } /// Debug location block: device coordinates + fix age, then the in-range - /// indicator. "In range" mirrors the live alert criteria — stations selling - /// the monitored fuel within the alert radius of the last fix. + /// indicator. "Cheapest in range" mirrors the app list (selected fuel + + /// stationLimit radius) via `appCheapest`; the "In range" count mirrors + /// the alert prediction (alert fuel + alert radius). @ViewBuilder private var debugLocationRows: some View { Divider() @@ -226,7 +230,18 @@ struct SettingsView: View { .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 { Label("Cheapest in range", systemImage: "fuelpump.fill") Spacer() diff --git a/FuelBoardWidgets/FuelPriceWidget.swift b/FuelBoardWidgets/FuelPriceWidget.swift index 0f20f60..3d89089 100644 --- a/FuelBoardWidgets/FuelPriceWidget.swift +++ b/FuelBoardWidgets/FuelPriceWidget.swift @@ -5,13 +5,18 @@ 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 (maps://?daddr=). The widget uses -// the native Maps scheme (not a custom app scheme) so that CarPlay can route -// the tap straight to Apple Maps — which IS a CarPlay app — without needing -// the containing app to launch. On the Home Screen the system either opens -// Maps directly or delivers the URL to FuelBoard, whose onOpenURL forwards it -// (and also still handles legacy fuelboard:// and http://maps.apple.com links -// from older cached timelines). +// Taps deep-link to Apple Maps directions (maps://?daddr=). On the Home +// Screen the system either opens Maps directly or delivers the URL to +// FuelBoard, whose onOpenURL forwards it (and also still handles legacy +// fuelboard:// and http://maps.apple.com links from older cached timelines). +// +// CarPlay: this widget is marked as a DISFAVORED location there. Widgets in +// 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 // (long-press → Edit Widget): fuel type (Unleaded/Premium/Diesel) and sort @@ -30,6 +35,24 @@ struct FuelPriceWidget: Widget { let kind = "FuelPriceWidget" 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( kind: kind, intent: FuelBoardWidgetConfigurationIntent.self, @@ -79,7 +102,9 @@ struct FuelPriceTimelineProvider: AppIntentTimelineProvider { in context: Context ) async -> Timeline { 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)) }