diff --git a/.gitignore b/.gitignore index f78762c..ff93732 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ build/ build-release/ build-sim/ +build-watch/ .DS_Store *.xcuserstate xcuserdata/ diff --git a/FuelBoard.xcodeproj/project.pbxproj b/FuelBoard.xcodeproj/project.pbxproj index 1aca269..fdea2e0 100644 --- a/FuelBoard.xcodeproj/project.pbxproj +++ b/FuelBoard.xcodeproj/project.pbxproj @@ -696,6 +696,7 @@ CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = F3BE6NE7U3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Config/App-Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( @@ -718,6 +719,7 @@ CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = F3BE6NE7U3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Config/App-Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( @@ -740,6 +742,7 @@ CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = F3BE6NE7U3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Config/Widget-Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( @@ -764,6 +767,7 @@ CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = F3BE6NE7U3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "Config/Widget-Info.plist"; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index abb7edf..39534e3 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -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() diff --git a/FuelBoard/FuelBoardApp.swift b/FuelBoard/FuelBoardApp.swift index 4513dd7..bd508f7 100644 --- a/FuelBoard/FuelBoardApp.swift +++ b/FuelBoard/FuelBoardApp.swift @@ -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 diff --git a/FuelBoard/LiveActivityManager.swift b/FuelBoard/LiveActivityManager.swift index 936d054..cd0297a 100644 --- a/FuelBoard/LiveActivityManager.swift +++ b/FuelBoard/LiveActivityManager.swift @@ -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() diff --git a/FuelBoard/RoadDistanceService.swift b/FuelBoard/RoadDistanceService.swift new file mode 100644 index 0000000..b2f8412 --- /dev/null +++ b/FuelBoard/RoadDistanceService.swift @@ -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)) + } +} diff --git a/FuelBoard/SiriShortcuts.swift b/FuelBoard/SiriShortcuts.swift index a2012fa..4984f7f 100644 --- a/FuelBoard/SiriShortcuts.swift +++ b/FuelBoard/SiriShortcuts.swift @@ -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" diff --git a/FuelBoard/StationsView.swift b/FuelBoard/StationsView.swift index 6e01e14..54ec67e 100644 --- a/FuelBoard/StationsView.swift +++ b/FuelBoard/StationsView.swift @@ -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 diff --git a/FuelBoard/TrendsView.swift b/FuelBoard/TrendsView.swift index b8303a5..33e867e 100644 --- a/FuelBoard/TrendsView.swift +++ b/FuelBoard/TrendsView.swift @@ -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. diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index 62fd5ab..835342e 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -620,3 +620,82 @@ final class OfflineDataLabelTests: XCTestCase { XCTAssertNil(FuelStore.offlineDataLabel(from: "not-a-date")) } } + +// MARK: - Road distance cache + +final class RoadDistanceCacheTests: XCTestCase { + override func setUp() { + super.setUp() + // Keychain persists across invocations, so a cache left by an earlier + // test or run would pollute these. Overwrite with an empty, far-away + // cache (source at (0,0)) so every test starts from a clean slate. + FuelStore.saveRoadDistances(sourceLat: 0, sourceLng: 0, entries: [:]) + } + + private func station(_ id: String, _ lat: Double, _ lng: Double) -> FuelStation { + FuelStation(id: id, name: id, brand: "X", address: "", postcode: "", + lat: lat, lng: lng, prices: [:], priceUpdated: nil) + } + + func testDisplayDistanceFallsBackToStraightLineWhenNoCache() { + // London user, station ~ London -> no cache -> straight-line haversine. + let s = station("a", 51.5074, -0.1278) + let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1) + XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001) + } + + func testRoadDistanceUsedWhenCachedNear() { + let s = station("a", 51.5074, -0.1278) + // Cache a road distance of 3.2 km for this station from the user's fix. + FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, + entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)]) + let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1) + XCTAssertEqual(km, 3.2, accuracy: 0.0001) + } + + func testRoadDistanceNilWhenOriginFar() { + let s = station("a", 51.5074, -0.1278) + // Cache built in London, but the user is now ~200 km away -> stale. + FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278, + entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)]) + let meters = FuelStore.roadDistanceMeters(for: s, userLat: 53.4808, userLng: -2.2426) + XCTAssertNil(meters) + // And display falls back to straight-line. + let km = FuelStore.displayDistanceKM(station: s, userLat: 53.4808, userLng: -2.2426) + XCTAssertEqual(km, s.distanceKM(to: 53.4808, lng2: -2.2426), accuracy: 0.0001) + } + + func testRoadDistanceUsedForOtherStationNotFound() { + FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, + entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)]) + // A station that isn't in the cache falls back to straight-line. + let s = station("z", 51.51, -0.13) + let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1) + XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001) + } + + func testRoadDistanceNotServedWhenStationPinDiffers() { + // Route a road distance to station "a" at pin P1. + FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, + entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)]) + // The SAME station id appears with a moved pin (corrected coordinate / + // different embedded vs live source): the cached route to P1 must NOT + // be served — it belongs to a different location. + let moved = station("a", 51.5400, -0.1600) + let km = FuelStore.displayDistanceKM(station: moved, userLat: 51.6, userLng: -0.1) + XCTAssertEqual(km, moved.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001, + "road value routed to the old pin leaked onto a different coordinate") + } +} + +// MARK: - Install identity + +final class InstallIdentityTests: XCTestCase { + func testFreshInstallIsStableAfterFirstCall() { + // The first call seeds the common install id (local == keychain), so + // any subsequent call in the same process must report NOT-fresh. This + // holds regardless of persisted keychain/defaults state from prior runs. + _ = FuelStore.isFreshInstall() + XCTAssertFalse(FuelStore.isFreshInstall()) + } +} diff --git a/FuelBoardWidgets/FuelBoardLiveActivityView.swift b/FuelBoardWidgets/FuelBoardLiveActivityView.swift index 8d6a242..8b214f4 100644 --- a/FuelBoardWidgets/FuelBoardLiveActivityView.swift +++ b/FuelBoardWidgets/FuelBoardLiveActivityView.swift @@ -41,7 +41,7 @@ struct FuelBoardLiveActivity: Widget { } } compactLeading: { Image(systemName: "fuelpump.fill") - .foregroundStyle(.green) + .foregroundStyle(context.state.fuel.tintColor) } compactTrailing: { FuelBoardLiveActivityPriceView(context: context) } minimal: { @@ -49,12 +49,15 @@ struct FuelBoardLiveActivity: Widget { .font(.caption2.bold().monospacedDigit()) } } - // NOTE: deliberately NO `.supplementalActivityFamilies([.small])`. - // That modifier makes iOS eligible to render this activity in the - // narrow `.small` form on the iPhone/iPad Lock Screen, which is what - // produced the squeezed, small-text card. Dropping it keeps the - // full-width Lock Screen card on iPhone/iPad; CarPlay still shows a - // small form via the Dynamic Island compact closures below. + .supplementalActivityFamilies([.small]) + // Why `.small` is kept: it lets the SHARED body render a compact form + // in genuinely small slots (CarPlay small / Apple Watch smart stack) + // instead of falling back to the Dynamic Island compact closure — + // which could NOT show the station distance the user wants on CarPlay. + // The full-width iPhone/iPad card is protected by the `richMinWidth` + // gate on `richBody` + its flexible, truncating middle column, so + // iPhone/iPad still get the full card; only truly small space picks + // the compact strip below. } } @@ -62,29 +65,59 @@ struct FuelBoardLiveActivity: Widget { private struct FuelBoardLiveActivityView: View { let context: ActivityViewContext + /// Below this ACTUAL proposed width we show the compact strip (CarPlay + /// small / Watch smart stack); at/above it we show the full card. The + /// decision is made from the real width the system hands the body, read + /// via a background GeometryReader — NOT ViewThatFits ideal-width + /// measurement (that's broken for truncating text: a long station name + /// inflated the "ideal" width past the iPhone Lock Screen and collapsed + /// the full card). + private let compactWidthThreshold: CGFloat = 280 + + /// Measured slot width (drives the rich-vs-compact branch). Measured in a + /// background GeometryReader so it does NOT act as the layout container: + /// a GeometryReader root pins content top-left, and forcing a + /// maxHeight:.infinity frame on it over-claims the whole proposed height, + /// centring the content below true vertical centre (bigger gap above) on + /// the Lock Screen. Measuring behind the scenes keeps the content + /// intrinsic-sized so the system vertically centres it itself. + @State private var slotWidth: CGFloat = 400 + var body: some View { Link(destination: context.state.mapsURL) { - // Always the full three-column card. The station caption is - // line-limited + scale-down + tail-truncated, so a LONG station - // name truncates in place instead of inflating this view's ideal - // width and tricking ViewThatFits into falling back to the compact - // strip (that is exactly what made 5-mi / long-named activities - // render small while 10-15-mi / short names stayed full). - // - // No ViewThatFits / compactBody: with `.supplementalActivityFamilies` - // removed, this body is only ever handed Lock-Screen width, so the - // compact fallback was both dead weight and the cause of the bug. - richBody + Group { + // Branch on the ACTUAL proposed width. iPhone/iPad offer the + // full Lock Screen width (>= threshold) → rich card, no matter + // how long the station name is. Truly small slots (CarPlay / + // Watch) offer much less → compact strip. + if slotWidth >= compactWidthThreshold { + richBody + } else { + compactBody + } + } + // Fill the card width so the background measure reads the real + // slot, not the intrinsic content width. + .frame(maxWidth: .infinity) + // Side-channel width measurement — never the layout container. + .background( + GeometryReader { geo in + Color.clear + .onAppear { slotWidth = geo.size.width } + .onChange(of: geo.size.width) { _, w in slotWidth = w } + } + ) } } /// Full three-column design (unchanged): brand glyph · fuel+station · price. private var richBody: some View { HStack(spacing: 12) { - // LEFT — station brand glyph - Image(systemName: "fuelpump.circle.fill") - .font(.system(size: 32)) - .foregroundStyle(.green, .white) + // LEFT — station brand glyph: the fuel-tinted pump on its own. No + // background circle behind it (user request). + Image(systemName: "fuelpump.fill") + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(context.state.fuel.tintColor) .frame(width: 40, height: 40) // MIDDLE — fuel + station @@ -113,7 +146,31 @@ private struct FuelBoardLiveActivityView: View { .padding() } - // NOTE: `compactBody` was removed — always render `richBody` (see body). + /// Minimal strip for small space (CarPlay small / Watch smart stack): + /// fuel type + bold price on one line, station · distance below. + /// Deliberately no app name and no "Tap for directions" — CarPlay is + /// display-only, and the user's asks here are just fuel + price + distance. + private var compactBody: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 5) { + Text(context.state.fuel.displayName) + .font(.caption.bold()) + .lineLimit(1) + Spacer(minLength: 4) + FuelStore.priceTextAttributed(context.state.pricePence, + style: context.state.priceDisplayStyle, + size: 15, weight: .bold) + .lineLimit(1) + } + Text("\(context.state.stationName) · \(context.state.distanceText)") + .font(.system(size: 9)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.7) + .truncationMode(.tail) + } + .padding(8) + } } /// Dynamic Island expanded regions + compact trailing — price only. diff --git a/Shared/FuelPriceWidgetViews.swift b/Shared/FuelPriceWidgetViews.swift index 31e8a0d..cb898b9 100644 --- a/Shared/FuelPriceWidgetViews.swift +++ b/Shared/FuelPriceWidgetViews.swift @@ -89,7 +89,7 @@ struct FuelPriceWidgetContent: View { FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green) } if let location = entry.location { - Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) + " away") + Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)) + " away") .font(.caption2) .foregroundStyle(.secondary) } else { @@ -148,7 +148,7 @@ struct FuelPriceWidgetContent: View { .font(.caption.weight(.semibold)) .lineLimit(1) if let location = entry.location { - Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng))) + Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng))) .font(.caption2) .foregroundStyle(.secondary) } diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index b63138c..659ab54 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -8,6 +8,7 @@ // keychain → app-group defaults → fallback. import Foundation +import SwiftUI import Security #if canImport(AppIntents) import AppIntents @@ -99,6 +100,20 @@ enum FuelType: String, Codable, CaseIterable, Identifiable { } } +/// Fuel colour wheel (user-chosen palette): green = unleaded (#30D158), +/// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Lives here in Shared +/// so the app, widget, and Live Activity all tint the pump/fuel glyphs from one +/// definition. +extension FuelType { + 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 + } + } +} + #if canImport(AppIntents) extension FuelType: AppEnum {} #endif @@ -949,15 +964,127 @@ struct FuelStore { } // MARK: Onboarding — the app shows the intro screen on first launch only - // (a test button in the Alerts tab re-opens it). Stored in the app group - // so the widget can see it too if ever needed. + // (a test button in the Alerts tab re-opens it). Stored KEYCHAIN-FIRST + // (with an app-group mirror) for the same reason as favourites/distance + // unit: free SideStore accounts don't provision the app-group container, + // so an app-group-only flag silently fails to save AND reloads as false, + // making onboarding re-appear on every launch. Keychain survives reinstall + // and is shared with the extension. static func loadHasCompletedOnboarding() -> Bool { - UserDefaults(suiteName: appGroupSuite)?.bool(forKey: onboardingCompletedKey) ?? false + (loadString(service: onboardingCompletedKey) ?? "0") == "1" } static func saveHasCompletedOnboarding(_ completed: Bool) { - UserDefaults(suiteName: appGroupSuite)?.set(completed, forKey: onboardingCompletedKey) + saveString(completed ? "1" : "0", service: onboardingCompletedKey) + } + + // MARK: Install identity — distinguish a genuinely fresh install (where + // onboarding should replay) from later launches of the same install. + + /// Keychain copy of the install id (survives reinstall). + private static let installIDKey = "fuelboard.installID" + /// Local (app-own container) copy — wiped on reinstall, persists across + /// normal launches. Free SideStore accounts have no app-group container, + /// so this app-own defaults domain is the reliable "same install" signal. + private static let localInstallIDKey = "fuelboard.installID.local" + + /// True when this is a first-ever install OR the app was just reinstalled + /// (local install id missing/different from the keychain id). Seeds a fresh + /// id into both stores so the next launch within the same install is not a + /// "fresh install" any more. + static func isFreshInstall() -> Bool { + let local = UserDefaults.standard.string(forKey: localInstallIDKey) + let remote = loadString(service: installIDKey) + if let local, let remote, local == remote { return false } + // Fresh / mismatched install (or first launch). Pattern a common id so + // subsequent launches of this install are recognised as the same one. + let id = UUID().uuidString + UserDefaults.standard.set(id, forKey: localInstallIDKey) + saveString(id, service: installIDKey) + return true + } + + /// Onboarding should present when it hasn't been completed in this install + /// OR this is a freshly-installed app (so the walkthrough replays on a new + /// install, e.g. after a SideStore reinstall, without re-showing on every + /// ordinary launch). The install check always runs (it seeds the id) rather + /// than short-circuiting, so a brand-new install is recorded before the + /// user ever fills in onboarding. + static func shouldShowOnboarding() -> Bool { + let fresh = isFreshInstall() + return !loadHasCompletedOnboarding() || fresh + } + + // MARK: Road distances (Apple-Maps-matched, computed by the app) + + /// Cached road/routed distances (metres) keyed by station ID, computed by + /// the app via MapKit `MKDirections`. Stored in KEYCHAIN (survives on free + /// SideStore accounts where the app-group container isn't provisioned) so + /// the widget extension can read it too. Widget + Live Activity prefer + /// these over straight-line haversine for the displayed distance. + static let roadDistancesKey = "fuelboard.roadDistances" + + /// How far (metres) the cache's source location may be from the current + /// user position before a cached road distance is treated as stale. + static let roadDistanceOriginToleranceMeters: Double = 600 + + /// How far (degrees) a station's stored coordinate may drift from the pin + /// a road distance was actually routed to before that cached value is + /// treated as belonging to a different station. Guards against one data + /// source (live fetch, bundled offline dump, or a corrected pin) serving a + /// road distance that was computed for a different coordinate under the + /// same station ID. ~1e-4 deg ≈ 11 m — tolerates float/rounding jitter but + /// catches any real pin change. + static let roadDistancePinToleranceDegrees: Double = 1e-4 + + static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: CachedRoadDistance]) { + let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng, + updatedAt: Date().timeIntervalSince1970, entries: entries) + if let data = try? JSONEncoder().encode(cache) { + saveString(data.base64EncodedString(), service: roadDistancesKey) + } + } + + static func loadRoadDistances() -> RoadDistanceCache? { + guard let raw = loadString(service: roadDistancesKey), + let data = Data(base64Encoded: raw), + let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data) + else { return nil } + return cache + } + + /// Cached road distance (metres) to a station from the user's location, or + /// nil when not cached / the cache was built too far from where the user + /// is now / the station's own pin doesn't match the coordinate that was + /// routed. + static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? { + guard let cache = loadRoadDistances(), + let entry = cache.entries[station.id] else { return nil } + // The cache is only valid near the location it was built from. + let dLat = (userLat - cache.sourceLat) * .pi / 180 + let dLng = (userLng - cache.sourceLng) * .pi / 180 + let r = 6371000.0 + let a = sin(dLat / 2) * sin(dLat / 2) + + cos(cache.sourceLat * .pi / 180) * cos(userLat * .pi / 180) * + sin(dLng / 2) * sin(dLng / 2) + let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a)) + guard originDistanceMeters <= roadDistanceOriginToleranceMeters else { return nil } + // Pin fingerprint: never serve a routed value for a coordinate we + // didn't actually route to. This is the guard that keeps embedded / + // live / cached station sets from injecting each other's road metres. + guard abs(entry.lat - station.lat) <= roadDistancePinToleranceDegrees, + abs(entry.lng - station.lng) <= roadDistancePinToleranceDegrees else { return nil } + return entry.meters + } + + /// Distance (km) to display for a station: cached ROAD distance when + /// available (matches Apple Maps), else straight-line haversine. + static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double { + if let meters = roadDistanceMeters(for: station, userLat: userLat, userLng: userLng) { + return meters / 1000.0 + } + return station.distanceKM(to: userLat, lng2: userLng) } // MARK: Low-level keychain helpers @@ -1019,3 +1146,23 @@ struct FuelStore { loadString(service: "widget.diag.\(intentType)") } } + +/// Cached Apple-Maps road distances for nearby stations (see +/// A single cached road distance plus the station pin it was routed to. Keeping +/// the pin lets `roadDistanceMeters` refuse to serve a route computed for a +/// *different* coordinate under the same ID — the guard that stops embedded / +/// live / cached station sets cross-contaminating the distance display. +struct CachedRoadDistance: Codable { + let meters: Double + let lat: Double + let lng: Double +} + +/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres + +/// the routed pin. +struct RoadDistanceCache: Codable { + let sourceLat: Double + let sourceLng: Double + let updatedAt: TimeInterval + let entries: [String: CachedRoadDistance] +}