Merge main into feature/watch-app
This commit is contained in:
@@ -50,6 +50,23 @@ struct ContentView: View {
|
||||
// fires on every fix incl. background significant-change
|
||||
// wake-ups, so the Lock Screen pill stays live while driving.
|
||||
updateLiveActivity()
|
||||
refreshRoadDistancesIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
/// Kicks off a (throttled) Apple-Maps road-distance recompute for the
|
||||
/// stations around the current fix. The app owns routing — the widget and
|
||||
/// Live Activity only read the cached result. Falls back to the last saved
|
||||
/// location so it can run before the first fresh GPS fix arrives.
|
||||
private func refreshRoadDistancesIfNeeded() {
|
||||
let origin = location ?? FuelStore.loadLocation()
|
||||
guard let origin else { return }
|
||||
Task {
|
||||
await RoadDistanceService.refreshIfNeeded(
|
||||
stations: stations,
|
||||
lat: origin.lat,
|
||||
lng: origin.lng
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +321,7 @@ struct ContentView: View {
|
||||
#else
|
||||
let shouldSkipOnboarding = false
|
||||
#endif
|
||||
if FuelStore.loadHasCompletedOnboarding()
|
||||
if !FuelStore.shouldShowOnboarding()
|
||||
|| shouldSkipOnboarding {
|
||||
locationManager.startForegroundTracking()
|
||||
// Geofences and the Live Activity must follow the user even in
|
||||
@@ -317,6 +334,10 @@ struct ContentView: View {
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
monitor.setEnabled(alertsEnabled)
|
||||
updateLiveActivity()
|
||||
// Compute road distances early (throttled; falls back to the
|
||||
// last saved location) so distance surfaces are road-matched
|
||||
// as soon as stations are available.
|
||||
refreshRoadDistancesIfNeeded()
|
||||
// Refresh only when the cache is stale (twice-a-day policy).
|
||||
// Skipped under the force-* hooks so the banner stays up.
|
||||
#if DEBUG
|
||||
@@ -362,6 +383,7 @@ struct ContentView: View {
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
updateLiveActivity()
|
||||
refreshRoadDistancesIfNeeded()
|
||||
if FuelStore.hasPendingWatchRefreshRequest {
|
||||
Task { await handlePendingWatchRefreshRequest() }
|
||||
}
|
||||
@@ -380,6 +402,7 @@ struct ContentView: View {
|
||||
monitor.update(stations: stations, favourites: refreshedFavourites,
|
||||
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
|
||||
updateLiveActivity()
|
||||
refreshRoadDistancesIfNeeded()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -963,7 +986,7 @@ struct StationRow: View {
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
if let location {
|
||||
Text(distanceUnit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
|
||||
Text(distanceUnit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import ActivityKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct FuelBoardApp: App {
|
||||
init() {
|
||||
#if DEBUG
|
||||
// QA hook (Debug builds only): `-qaLiveActivity e10|e5|diesel` starts a
|
||||
// Live Activity with a long station name so the Lock Screen / island
|
||||
// layout can be rendered in the Simulator for visual QA.
|
||||
let args = ProcessInfo.processInfo.arguments
|
||||
if let idx = args.firstIndex(of: "-qaLiveActivity"),
|
||||
args.indices.contains(idx + 1),
|
||||
let fuel = FuelType(rawValue: args[idx + 1]) {
|
||||
startQALiveActivity(fuel: fuel)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
@@ -9,6 +24,34 @@ struct FuelBoardApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func startQALiveActivity(fuel: FuelType) {
|
||||
let state = FuelBoardLiveActivityAttributes.ContentState(
|
||||
fuel: fuel,
|
||||
stationID: "qa-phoenix",
|
||||
stationName: "Phoenix Filling Stations",
|
||||
brand: "Phoenix",
|
||||
pricePence: 1499,
|
||||
priceDisplayStyle: FuelStore.loadPriceDisplayStyle(),
|
||||
distanceKM: 8.0,
|
||||
lat: 51.5,
|
||||
lng: -0.12,
|
||||
updatedAt: Date()
|
||||
)
|
||||
let attrs = FuelBoardLiveActivityAttributes()
|
||||
do {
|
||||
let activity = try Activity.request(
|
||||
attributes: attrs,
|
||||
content: .init(state: state, staleDate: nil),
|
||||
pushType: nil
|
||||
)
|
||||
print("QA-LIVE-ACTIVITY STARTED id=\(activity.id)")
|
||||
} catch {
|
||||
print("QA-LIVE-ACTIVITY FAILED: \(error)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Handles deep links that end up in the app. Widget taps arrive here in
|
||||
/// two cases:
|
||||
/// - legacy/cached widget timelines using the `fuelboard://` relay, or
|
||||
|
||||
@@ -57,7 +57,7 @@ enum LiveActivityManager {
|
||||
brand: best.brand,
|
||||
pricePence: price,
|
||||
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
|
||||
distanceKM: best.distanceKM(to: location.lat, lng2: location.lng),
|
||||
distanceKM: FuelStore.displayDistanceKM(station: best, userLat: location.lat, userLng: location.lng),
|
||||
lat: best.lat,
|
||||
lng: best.lng,
|
||||
updatedAt: Date()
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// RoadDistanceService.swift — computes Apple-Maps-matched ROAD distances for
|
||||
// nearby stations and caches them (keychain) so widgets + Live Activity can
|
||||
// show real driving distance instead of straight-line haversine.
|
||||
//
|
||||
// Runs only in the APP: MKDirections is network-bound and the widget extension
|
||||
// has a tiny execution budget + a ~40-70/day refresh budget, so routing belongs
|
||||
// here, not in the widget. The widget/Live Activity just read the cache.
|
||||
//
|
||||
// Throttling: recompute at most every `throttleMinutes`, or when the user has
|
||||
// moved `moveThresholdMeters` from where the cache was built. Bounded to the
|
||||
// `candidatesPerPass` nearest stations so a pass stays a handful of route calls.
|
||||
|
||||
import Foundation
|
||||
import MapKit
|
||||
import WidgetKit
|
||||
|
||||
enum RoadDistanceService {
|
||||
/// Upper bound on stations routed per pass, so a pass stays a bounded set of
|
||||
/// route calls. Raised from 12 so stations past the old nearest-12 cutoff
|
||||
/// still get real road distances instead of a straight-line fallback.
|
||||
static let candidatesPerPass = 40
|
||||
/// Only route stations within this straight-line radius (km). Covers the
|
||||
/// largest search radius the UI exposes (15 mi ≈ 24.1 km) plus margin, so
|
||||
/// every station a widget/Live Activity/list can actually show gets routed.
|
||||
static let maxRadiusKM: Double = 25
|
||||
/// Don't route again more often than this (minutes).
|
||||
static let throttleMinutes: Double = 10
|
||||
/// Recompute when the user moves more than this (metres) from the last
|
||||
/// source location.
|
||||
static let moveThresholdMeters: Double = 400
|
||||
|
||||
/// Refreshes the cached road distances for the in-radius stations around
|
||||
/// `lat`/`lng`. Throttled by time + distance; safe to call on every fix.
|
||||
static func refreshIfNeeded(stations: [FuelStation], lat: Double, lng: Double) async {
|
||||
guard !stations.isEmpty else { return }
|
||||
|
||||
// Throttle: keep cached values when fresh and the user hasn't moved far.
|
||||
if let cache = FuelStore.loadRoadDistances() {
|
||||
let elapsed = Date().timeIntervalSince1970 - cache.updatedAt
|
||||
let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng)
|
||||
if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Candidate stations: the nearest-by-straight-line subset that the UI
|
||||
// could actually display, capped so a pass stays bounded.
|
||||
let nearest = stations
|
||||
.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
|
||||
.prefix(candidatesPerPass)
|
||||
.filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM }
|
||||
|
||||
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
||||
var entries: [String: CachedRoadDistance] = [:]
|
||||
for station in nearest {
|
||||
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
||||
if let meters = await roadMeters(from: origin, to: dest) {
|
||||
// Store the exact pin that was routed so the display layer can
|
||||
// refuse to serve this value if the station later appears with
|
||||
// a different coordinate (corrected pin / other data source).
|
||||
entries[station.id] = CachedRoadDistance(meters: meters,
|
||||
lat: station.lat,
|
||||
lng: station.lng)
|
||||
}
|
||||
}
|
||||
guard !entries.isEmpty else { return }
|
||||
|
||||
FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries)
|
||||
// Wake the widgets so the new road distances surface immediately.
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
|
||||
/// Driving distance (metres) between two coordinates via Apple Maps routing.
|
||||
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
|
||||
let request = MKDirections.Request()
|
||||
request.source = MKMapItem(placemark: MKPlacemark(coordinate: from))
|
||||
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: to))
|
||||
request.transportType = .automobile
|
||||
request.requestsAlternateRoutes = false
|
||||
do {
|
||||
let response = try await MKDirections(request: request).calculate()
|
||||
return response.routes.first?.distance
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Straight-line haversine distance between two coordinates, in metres.
|
||||
private static func haversineMeters(_ lat1: Double, _ lng1: Double, _ lat2: Double, _ lng2: Double) -> Double {
|
||||
let r = 6371000.0
|
||||
let dLat = (lat2 - lat1) * .pi / 180
|
||||
let dLng = (lng2 - lng1) * .pi / 180
|
||||
let a = sin(dLat / 2) * sin(dLat / 2) +
|
||||
cos(lat1 * .pi / 180) * cos(lat2 * .pi / 180) *
|
||||
sin(dLng / 2) * sin(dLng / 2)
|
||||
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ struct CheapestFuelIntent: AppIntent {
|
||||
struct DirectionsToCheapestFuelIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Directions to Cheapest Fuel Near Me"
|
||||
static var description = IntentDescription(
|
||||
"Opens Apple Maps directions to the cheapest station selling a fuel near you, using the latest cached prices."
|
||||
"Opens directions to the cheapest station selling a fuel near you, using the latest cached prices."
|
||||
)
|
||||
|
||||
@Parameter(title: "Fuel")
|
||||
@@ -285,7 +285,7 @@ struct FavouriteFuelPriceIntent: AppIntent {
|
||||
struct DirectionsToFavouriteFuelIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Directions to Favourite Fuel Station"
|
||||
static var description = IntentDescription(
|
||||
"Opens Apple Maps directions to your top favourite station for a fuel."
|
||||
"Opens directions to your top favourite station for a fuel."
|
||||
)
|
||||
|
||||
@Parameter(title: "Fuel")
|
||||
@@ -404,7 +404,7 @@ struct FuelMessage: View {
|
||||
/// Shortcut slot and adds no phrase.
|
||||
struct OpenDirectionsIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Directions"
|
||||
static var description = IntentDescription("Opens Apple Maps directions to the station.")
|
||||
static var description = IntentDescription("Opens directions to the station.")
|
||||
static var isDiscoverable: Bool = false
|
||||
|
||||
@Parameter var stationName: String
|
||||
@@ -489,19 +489,14 @@ struct FuelMessageSnippetIntent: SnippetIntent {
|
||||
|
||||
struct FuelBoardShortcuts: AppShortcutsProvider {
|
||||
static var appShortcuts: [AppShortcut] {
|
||||
// Generic parameterized shortcut — matches whatever fuel word Siri
|
||||
// resolves. Parameter resolution is flaky on-device ("cheapest diesel"
|
||||
// matched, "cheapest unleaded" didn't), which is why the fixed-fuel
|
||||
// entries below carry the fuel word as LITERAL phrase text.
|
||||
AppShortcut(
|
||||
intent: CheapestFuelIntent(),
|
||||
phrases: [
|
||||
"Ask \(.applicationName) what's the cheapest \(\.$fuel) near me",
|
||||
],
|
||||
shortTitle: "Cheapest Fuel",
|
||||
systemImageName: "fuelpump"
|
||||
)
|
||||
|
||||
// NOTE (2026-08-20): the generic parameterized shortcut ("cheapest
|
||||
// ${fuel}") was REMOVED. It competed with the fixed-fuel literal-word
|
||||
// shortcuts for the same intent (parameter resolution was already flaky
|
||||
// on-device), which degraded Siri's NLU ranking and caused the
|
||||
// hit-and-miss "can't do that, searching in app" fallback. Now Siri
|
||||
// routes on literal fuel words only, and the freed slot keeps us under
|
||||
// the 10-shortcut cap. Every phrase carries .applicationName (iOS 26
|
||||
// metadata-processor requirement) and mirrors how people actually ask.
|
||||
AppShortcut(
|
||||
intent: CheapestFuelIntent(fuel: .e10),
|
||||
phrases: [
|
||||
@@ -509,6 +504,10 @@ struct FuelBoardShortcuts: AppShortcutsProvider {
|
||||
"Ask \(.applicationName) for the cheapest petrol near me",
|
||||
"Find the cheapest petrol near me \(.applicationName)",
|
||||
"Find the cheapest unleaded near me \(.applicationName)",
|
||||
"What's the cheapest unleaded near me \(.applicationName)",
|
||||
"What's the cheapest petrol near me \(.applicationName)",
|
||||
"Cheapest unleaded near me \(.applicationName)",
|
||||
"Where's the cheapest petrol \(.applicationName)",
|
||||
],
|
||||
shortTitle: "Cheapest Unleaded",
|
||||
systemImageName: "fuelpump"
|
||||
|
||||
@@ -317,17 +317,6 @@ extension FuelType {
|
||||
case .diesel: return "Diesel"
|
||||
}
|
||||
}
|
||||
|
||||
/// Fuel colour wheel (user-chosen palette): green = unleaded (#30D158),
|
||||
/// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Used for the
|
||||
/// fuel-type tab icons and the title icon.
|
||||
var tintColor: Color {
|
||||
switch self {
|
||||
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
|
||||
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
|
||||
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fuel-type selector styled like a segmented control, with a coloured pump
|
||||
|
||||
@@ -25,6 +25,7 @@ struct TrendsView: View {
|
||||
var onHistoryRecovered: (() -> Void)? = nil
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
@State private var fuel: FuelType = .e10
|
||||
@State private var rangeDays: Int = 30
|
||||
@@ -34,6 +35,13 @@ struct TrendsView: View {
|
||||
@State private var loadFailed = false
|
||||
@State private var firstSnapshot: String?
|
||||
|
||||
/// Per-series draw-in waterline: maps stationID → how many leading points
|
||||
/// are revealed, so each line traces left→right on first appearance.
|
||||
/// Series reveal in a short staggered cascade so the draw is clearly
|
||||
/// visible even with only 2 points per line. Stays full after the first
|
||||
/// reveal so range/mode switches morph instead of re-tracing.
|
||||
@State private var revealed: [String: Int] = [:]
|
||||
|
||||
/// Seeded from `selectedFuel` (the fuel the tab was on) so the sheet
|
||||
/// opens where the user was — same pattern as FavouritesView.
|
||||
init(favourites: [FavouriteEntry],
|
||||
@@ -157,7 +165,13 @@ struct TrendsView: View {
|
||||
// pointer probe already done above.
|
||||
loadFailed = firstSnapshot == nil
|
||||
}
|
||||
// Always redraw the lines on a selection change. `.task(id:)` fires on
|
||||
// the initial appear and on every fuel/range switch (there's no
|
||||
// periodic refetch in this sheet), so a staggered per-series draw-in
|
||||
// replays exactly when the user picks 7/30/90 (or switches fuel) while
|
||||
// a width-stable morph keeps the x-axis/y-range from jumping abruptly.
|
||||
series = fetched
|
||||
revealSeries(fetched)
|
||||
// A failure with no data IS a connection problem — raise the global
|
||||
// banner so the user isn't stuck with a silent retry state. Success
|
||||
// clears it (only if the banner is the connection banner).
|
||||
@@ -168,6 +182,32 @@ struct TrendsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replays the staggered per-series draw-in for the given histories:
|
||||
/// resets the waterline, then cascades each station's line left→right,
|
||||
/// 0.18s apart, on every range/fuel selection. Reduce Motion jump-cuts
|
||||
/// straight to the full state.
|
||||
private func revealSeries(_ histories: [StationHistory]) {
|
||||
let hasPoints = histories.contains { !$0.points.isEmpty }
|
||||
guard hasPoints else { return }
|
||||
if reduceMotion {
|
||||
revealed = histories.reduce(into: [:]) { $0[$1.stationID] = $1.points.count }
|
||||
return
|
||||
}
|
||||
revealed = [:]
|
||||
let cascadeNS = UInt64(0.18 * 1_000_000_000)
|
||||
for (i, h) in histories.enumerated() {
|
||||
let sid = h.stationID
|
||||
let total = h.points.count
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: UInt64(i) * cascadeNS)
|
||||
guard !Task.isCancelled else { return }
|
||||
withAnimation(.easeOut(duration: 0.5)) {
|
||||
revealed[sid] = total
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func yLabel(_ pence: Double) -> String {
|
||||
switch mode {
|
||||
case .price:
|
||||
@@ -330,6 +370,9 @@ struct TrendsView: View {
|
||||
.font(.system(size: 30, weight: .bold, design: .default))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.primary)
|
||||
// Roll the digits to the new figure on range/mode change
|
||||
// (fires inside the animated transaction above).
|
||||
.contentTransition(.numericText(value: headAvg))
|
||||
if let delta = headlineDelta, delta != 0 {
|
||||
Label(
|
||||
"\(deltaIsGood ? "−" : "+")\(abs(delta), specifier: "%.1f")p",
|
||||
@@ -389,13 +432,13 @@ struct TrendsView: View {
|
||||
// type-checker's budget.)
|
||||
Chart {
|
||||
ForEach(displaySeries) { history in
|
||||
ForEach(history.points) { point in
|
||||
ForEach(history.points.prefix(revealed[history.stationID] ?? 0)) { point in
|
||||
areaMark(point, series: history.name,
|
||||
color: seriesColor(index(of: history.stationID)))
|
||||
}
|
||||
}
|
||||
ForEach(displaySeries) { history in
|
||||
ForEach(history.points) { point in
|
||||
ForEach(history.points.prefix(revealed[history.stationID] ?? 0)) { point in
|
||||
lineMark(point, series: history.name,
|
||||
color: seriesColor(index(of: history.stationID)))
|
||||
}
|
||||
@@ -424,6 +467,9 @@ struct TrendsView: View {
|
||||
}
|
||||
}
|
||||
.frame(height: 190)
|
||||
// Price ↔ vs-cheapest is a pure view toggle (no network): animate the
|
||||
// lines + axis gliding to the rebased series.
|
||||
.animation(.easeInOut(duration: 0.35), value: mode)
|
||||
}
|
||||
|
||||
/// One gradient-filled area band under a single point of a series.
|
||||
|
||||
Reference in New Issue
Block a user