From 01bb3ba90540466dd36a0526143616950db144bf Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 19 Aug 2026 18:31:36 +0100 Subject: [PATCH 01/21] live activity: compact CarPlay strip shows fuel + price + station distance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore .supplementalActivityFamilies([.small]) + a ViewThatFits compact fallback so CarPlay's small slot renders our shared compact strip (fuel type, bold price, station · distance) instead of falling back to the Dynamic Island compact closure, which showed the app name and omitted the distance the user wants. Full iPhone/iPad card is preserved via the richMinWidth gate on richBody plus its flexible, truncating middle column (long names can no longer collapse it). --- .../FuelBoardLiveActivityView.swift | 72 ++++++++++++++----- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/FuelBoardWidgets/FuelBoardLiveActivityView.swift b/FuelBoardWidgets/FuelBoardLiveActivityView.swift index 8d6a242..738eabe 100644 --- a/FuelBoardWidgets/FuelBoardLiveActivityView.swift +++ b/FuelBoardWidgets/FuelBoardLiveActivityView.swift @@ -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,19 +65,28 @@ struct FuelBoardLiveActivity: Widget { private struct FuelBoardLiveActivityView: View { let context: ActivityViewContext + /// Full card needs at least this much width; iPhone/iPad Lock Screen is + /// always wider, so they always get it. Only genuinely small slots + /// (CarPlay small / Watch smart stack) fall through to `compactBody`. + private let richMinWidth: CGFloat = 280 + /// Cap the compact strip so a mid-width surface can't sneak it in. + private let compactMaxWidth: CGFloat = 230 + 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 + ViewThatFits(in: .horizontal) { + // rich FIRST — wins on the full-width iPhone/iPad Lock Screen. + // Its middle column is flexible + line-limited, so a LONG + // station name truncates in place and never inflates the ideal + // width (which used to trick ViewThatFits into falling back to + // compact on iPhone). + richBody + .frame(minWidth: richMinWidth) + // compact fallback — the small CarPlay / Watch smart-stack + // strip: fuel · price · station distance (no app name). + compactBody + .frame(maxWidth: compactMaxWidth) + } } } @@ -113,7 +125,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. From 227cce4aa67253989f0f3f0438ed62f56bf3de11 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 19 Aug 2026 20:01:14 +0100 Subject: [PATCH 02/21] live activity: fix compact strip string interpolation (double backslash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compact CarPlay/Watch strip's second line had escaped interpolation (\\( instead of \\() so it rendered the literal placeholder text "context.state.stationName · context.state.distanceText" instead of the actual station name and distance. Normalize to single-backslash interpolation. --- FuelBoardWidgets/FuelBoardLiveActivityView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FuelBoardWidgets/FuelBoardLiveActivityView.swift b/FuelBoardWidgets/FuelBoardLiveActivityView.swift index 738eabe..ae46004 100644 --- a/FuelBoardWidgets/FuelBoardLiveActivityView.swift +++ b/FuelBoardWidgets/FuelBoardLiveActivityView.swift @@ -141,7 +141,7 @@ private struct FuelBoardLiveActivityView: View { size: 15, weight: .bold) .lineLimit(1) } - Text("\\(context.state.stationName) · \\(context.state.distanceText)") + Text("\(context.state.stationName) · \(context.state.distanceText)") .font(.system(size: 9)) .foregroundStyle(.secondary) .lineLimit(1) From a6620d36f745339511621d7abea4898fbaba7fe4 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 19 Aug 2026 22:08:07 +0100 Subject: [PATCH 03/21] live activity: select rich vs compact by actual GeometryReader width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ViewThatFits measures each child's IDEAL (untruncated) width to decide fit, and a flexible .frame(maxWidth:.infinity) middle column does not cap that ideal — so a long station name (e.g. 5-mi Phoenix Filling Stations) inflated richBody's ideal past the iPhone Lock Screen width and fell back to the compact strip. Replace the ViewThatFits branch with a GeometryReader that reads the ACTUAL proposed width and shows the full three-column card at/above 280pt, compact below. Content-independent, so a long name can no longer collapse the iPhone card. --- .../FuelBoardLiveActivityView.swift | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/FuelBoardWidgets/FuelBoardLiveActivityView.swift b/FuelBoardWidgets/FuelBoardLiveActivityView.swift index ae46004..4c51e37 100644 --- a/FuelBoardWidgets/FuelBoardLiveActivityView.swift +++ b/FuelBoardWidgets/FuelBoardLiveActivityView.swift @@ -65,27 +65,26 @@ struct FuelBoardLiveActivity: Widget { private struct FuelBoardLiveActivityView: View { let context: ActivityViewContext - /// Full card needs at least this much width; iPhone/iPad Lock Screen is - /// always wider, so they always get it. Only genuinely small slots - /// (CarPlay small / Watch smart stack) fall through to `compactBody`. - private let richMinWidth: CGFloat = 280 - /// Cap the compact strip so a mid-width surface can't sneak it in. - private let compactMaxWidth: CGFloat = 230 + /// 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 space the system hands the body via + /// GeometryReader, NOT from ViewThatFits ideal-width measurement — the + /// latter is broken for truncating text (a long station name inflates the + /// "ideal" width past the iPhone Lock Screen and collapses the full card). + private let compactWidthThreshold: CGFloat = 280 var body: some View { Link(destination: context.state.mapsURL) { - ViewThatFits(in: .horizontal) { - // rich FIRST — wins on the full-width iPhone/iPad Lock Screen. - // Its middle column is flexible + line-limited, so a LONG - // station name truncates in place and never inflates the ideal - // width (which used to trick ViewThatFits into falling back to - // compact on iPhone). - richBody - .frame(minWidth: richMinWidth) - // compact fallback — the small CarPlay / Watch smart-stack - // strip: fuel · price · station distance (no app name). - compactBody - .frame(maxWidth: compactMaxWidth) + GeometryReader { geo in + // 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 geo.size.width >= compactWidthThreshold { + richBody + } else { + compactBody + } } } } From 2c6e63f6b15c8043718a0f4270553e716d73191b Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 19 Aug 2026 22:18:56 +0100 Subject: [PATCH 04/21] live activity: tint pump glyph by monitored fuel Move FuelType.tintColor (green=unleaded #30D158, yellow=premium #FFD60A, cyan=diesel #64D2FF) into Shared/FuelStore.swift so the app, widget, and Live Activity all read one definition (was app-only in StationsView, so the widget couldn't see it). Tint the Live Activity rich card pump icon and the Dynamic Island compactLeading pump by context.state.fuel.tintColor. --- FuelBoard/StationsView.swift | 11 ----------- FuelBoardWidgets/FuelBoardLiveActivityView.swift | 4 ++-- Shared/FuelStore.swift | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 13 deletions(-) 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/FuelBoardWidgets/FuelBoardLiveActivityView.swift b/FuelBoardWidgets/FuelBoardLiveActivityView.swift index 4c51e37..49d9cd9 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: { @@ -95,7 +95,7 @@ private struct FuelBoardLiveActivityView: View { // LEFT — station brand glyph Image(systemName: "fuelpump.circle.fill") .font(.system(size: 32)) - .foregroundStyle(.green, .white) + .foregroundStyle(context.state.fuel.tintColor, .white) .frame(width: 40, height: 40) // MIDDLE — fuel + station diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index c47b2c6..6b51ed5 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 From bdfbbc605f916b95f8efda15f9bbf08756ba6523 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 19 Aug 2026 22:26:37 +0100 Subject: [PATCH 05/21] live activity: centre content vertically + darken pump circle A GeometryReader hands its content the full proposed frame and pins it top-left, so the Lock Screen card sat high instead of vertically centred when the activity container offered extra height. Wrap the branch in .frame(maxWidth:.infinity, maxHeight:.infinity, alignment:.center). Also darken the pump circle to 82% of the raw fuel tint (mix 18% black) so the white pump symbol keeps contrast against bright Lock Screen/card, esp. for the light yellow premium / cyan diesel palette colours. --- .../FuelBoardLiveActivityView.swift | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/FuelBoardWidgets/FuelBoardLiveActivityView.swift b/FuelBoardWidgets/FuelBoardLiveActivityView.swift index 49d9cd9..5fb58f6 100644 --- a/FuelBoardWidgets/FuelBoardLiveActivityView.swift +++ b/FuelBoardWidgets/FuelBoardLiveActivityView.swift @@ -73,6 +73,14 @@ private struct FuelBoardLiveActivityView: View { /// "ideal" width past the iPhone Lock Screen and collapses the full card). private let compactWidthThreshold: CGFloat = 280 + /// Slightly darkened fuel tint for the pump circle so the white pump + /// symbol keeps contrast against the bright Lock Screen / card background + /// (the raw palette colours like yellow #FFD60A are too light to sit under + /// a white glyph). + private var pumpCircleColor: Color { + context.state.fuel.tintColor.mix(with: .black, by: 0.18) + } + var body: some View { Link(destination: context.state.mapsURL) { GeometryReader { geo in @@ -80,11 +88,18 @@ private struct FuelBoardLiveActivityView: View { // 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 geo.size.width >= compactWidthThreshold { - richBody - } else { - compactBody + Group { + if geo.size.width >= compactWidthThreshold { + richBody + } else { + compactBody + } } + // A GeometryReader hands its content the FULL proposed frame + // (content is otherwise pinned top-left), so centre the card + // within that space — keeps the Lock Screen content vertically + // aligned instead of sitting high. + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } } } @@ -95,7 +110,7 @@ private struct FuelBoardLiveActivityView: View { // LEFT — station brand glyph Image(systemName: "fuelpump.circle.fill") .font(.system(size: 32)) - .foregroundStyle(context.state.fuel.tintColor, .white) + .foregroundStyle(pumpCircleColor, .white) .frame(width: 40, height: 40) // MIDDLE — fuel + station From 1d62204a7a6381b3c4d39f710a974ecc671ae2b0 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 19 Aug 2026 22:46:57 +0100 Subject: [PATCH 06/21] live activity: explicit badge (dark circle + bright pump); fix alignment look fuelpump.circle.fill's first hierarchical style hits the PUMP, not the circle, so the earlier darkened colour landed on the pump and the circle stayed white (user: 'I didn't want the fuel pump darker I wanted the white circle darker'). Rebuild the brand glyph from two explicit shapes: a light-grey (.86) Circle badge + a bright fuel-tinted fuelpump.fill, so circle vs pump colours are unambiguous and the badge centres cleanly with the two-line text block. Add a DEBUG-only -qaLiveActivity launch hook that starts a Live Activity with a long station name so the Lock Screen can be rendered in the Simulator and verified by pixels (pump bright green 43,193,81; badge grey 202,201,202). --- FuelBoard/FuelBoardApp.swift | 43 +++++++++++++++++++ .../FuelBoardLiveActivityView.swift | 32 +++++++++----- 2 files changed, 64 insertions(+), 11 deletions(-) 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/FuelBoardWidgets/FuelBoardLiveActivityView.swift b/FuelBoardWidgets/FuelBoardLiveActivityView.swift index 5fb58f6..d667180 100644 --- a/FuelBoardWidgets/FuelBoardLiveActivityView.swift +++ b/FuelBoardWidgets/FuelBoardLiveActivityView.swift @@ -73,12 +73,13 @@ private struct FuelBoardLiveActivityView: View { /// "ideal" width past the iPhone Lock Screen and collapses the full card). private let compactWidthThreshold: CGFloat = 280 - /// Slightly darkened fuel tint for the pump circle so the white pump - /// symbol keeps contrast against the bright Lock Screen / card background - /// (the raw palette colours like yellow #FFD60A are too light to sit under - /// a white glyph). - private var pumpCircleColor: Color { - context.state.fuel.tintColor.mix(with: .black, by: 0.18) + /// The badge circle behind the pump. The user calls this "the white + /// circle" and wants it slightly darkened for contrast against the bright + /// fuel pump. Built as an explicit grey disc (the light-grey "white circle + /// darker") — NOT part of the SF Symbol glyph, so it can never swallow the + /// darkened colour the way `fuelpump.circle.fill` did. + private var badgeCircleColor: Color { + Color(white: 0.86) } var body: some View { @@ -107,11 +108,20 @@ private struct FuelBoardLiveActivityView: View { /// 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(pumpCircleColor, .white) - .frame(width: 40, height: 40) + // LEFT — station brand badge: an explicit darkened circle with a + // bright fuel-tinted pump. Built from two shapes so the circle + // colour and pump colour are unambiguous (the myth that + // fuelpump.circle.fill's first style is the circle is what put the + // darkened colour on the pump). + ZStack { + Circle() + .fill(badgeCircleColor) + .frame(width: 40, height: 40) + Image(systemName: "fuelpump.fill") + .font(.system(size: 19, weight: .semibold)) + .foregroundStyle(context.state.fuel.tintColor) + } + .frame(width: 40, height: 40) // MIDDLE — fuel + station VStack(alignment: .leading, spacing: 2) { From d8412c132a214af70064bfe561db5322df79a67a Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 19 Aug 2026 22:52:50 +0100 Subject: [PATCH 07/21] live activity: remove the white/grey background circle behind the pump User: 'Remove the white background circle from the live activity'. Drop the explicit Circle badge and render the fuel-tinted fuelpump.fill on its own (sized up 19 -> 28 since it no longer sits inside a disc). --- .../FuelBoardLiveActivityView.swift | 29 ++++--------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/FuelBoardWidgets/FuelBoardLiveActivityView.swift b/FuelBoardWidgets/FuelBoardLiveActivityView.swift index d667180..0e191ca 100644 --- a/FuelBoardWidgets/FuelBoardLiveActivityView.swift +++ b/FuelBoardWidgets/FuelBoardLiveActivityView.swift @@ -73,15 +73,6 @@ private struct FuelBoardLiveActivityView: View { /// "ideal" width past the iPhone Lock Screen and collapses the full card). private let compactWidthThreshold: CGFloat = 280 - /// The badge circle behind the pump. The user calls this "the white - /// circle" and wants it slightly darkened for contrast against the bright - /// fuel pump. Built as an explicit grey disc (the light-grey "white circle - /// darker") — NOT part of the SF Symbol glyph, so it can never swallow the - /// darkened colour the way `fuelpump.circle.fill` did. - private var badgeCircleColor: Color { - Color(white: 0.86) - } - var body: some View { Link(destination: context.state.mapsURL) { GeometryReader { geo in @@ -108,20 +99,12 @@ private struct FuelBoardLiveActivityView: View { /// Full three-column design (unchanged): brand glyph · fuel+station · price. private var richBody: some View { HStack(spacing: 12) { - // LEFT — station brand badge: an explicit darkened circle with a - // bright fuel-tinted pump. Built from two shapes so the circle - // colour and pump colour are unambiguous (the myth that - // fuelpump.circle.fill's first style is the circle is what put the - // darkened colour on the pump). - ZStack { - Circle() - .fill(badgeCircleColor) - .frame(width: 40, height: 40) - Image(systemName: "fuelpump.fill") - .font(.system(size: 19, weight: .semibold)) - .foregroundStyle(context.state.fuel.tintColor) - } - .frame(width: 40, height: 40) + // 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 VStack(alignment: .leading, spacing: 2) { From 28bb245c93c9a0fb782e26f57ca33c60b1f0572a Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Thu, 20 Aug 2026 10:16:21 +0100 Subject: [PATCH 08/21] live activity: fix vertical centering on lock screen GeometryReader as the layout root pinned content top-left, and forcing maxHeight:.infinity over-claimed the whole proposed height, centring the content below true vertical centre (bigger gap above) on the Lock Screen. Demote GeometryReader to a background side-channel that only measures the slot width for the rich-vs-compact branch; the content stays intrinsic-sized so the system vertically centres it itself. --- .../FuelBoardLiveActivityView.swift | 46 ++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/FuelBoardWidgets/FuelBoardLiveActivityView.swift b/FuelBoardWidgets/FuelBoardLiveActivityView.swift index 0e191ca..8b214f4 100644 --- a/FuelBoardWidgets/FuelBoardLiveActivityView.swift +++ b/FuelBoardWidgets/FuelBoardLiveActivityView.swift @@ -67,32 +67,46 @@ private struct FuelBoardLiveActivityView: View { /// 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 space the system hands the body via - /// GeometryReader, NOT from ViewThatFits ideal-width measurement — the - /// latter is broken for truncating text (a long station name inflates the - /// "ideal" width past the iPhone Lock Screen and collapses the full card). + /// 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) { - GeometryReader { geo in + 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. - Group { - if geo.size.width >= compactWidthThreshold { - richBody - } else { - compactBody - } + if slotWidth >= compactWidthThreshold { + richBody + } else { + compactBody } - // A GeometryReader hands its content the FULL proposed frame - // (content is otherwise pinned top-left), so centre the card - // within that space — keeps the Lock Screen content vertically - // aligned instead of sitting high. - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } + // 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 } + } + ) } } From 64ef260959798133cfcf5597635b47496b5cd15a Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Thu, 20 Aug 2026 11:33:47 +0100 Subject: [PATCH 09/21] widget + live activity: prefer Apple-Maps road distance, computed by the app The widget/Live Activity showed straight-line haversine distance (0.8 mi) while Apple Maps routes 1.8 mi. Road routing is too heavy for the widget execution + ~40-70/day refresh budget, so the APP now computes it: - New RoadDistanceService (app target): for the nearest 12 stations each pass, calls MapKit MKDirections (free, no API key, matches Apple Maps) and caches metres keyed by station ID. - Cache stored in KEYCHAIN (fuelboard.roadDistances) so the widget reads it even on free SideStore accounts with no app-group container; only valid within 600 m of the location it was built from. - Throttled: recompute max every 10 min, or when the user moves > 400 m; wired into the location-update hook + location onChange. - Widget face, app station rows + Live Activity show the cached road distance, falling back to straight-line when absent. - 4 new cache tests (105 total). --- FuelBoard/ContentView.swift | 18 +++- FuelBoard/LiveActivityManager.swift | 2 +- FuelBoard/RoadDistanceService.swift | 87 +++++++++++++++++++ .../FuelBoardSharedTests/FuelBoardTests.swift | 43 +++++++++ Shared/FuelPriceWidgetViews.swift | 4 +- Shared/FuelStore.swift | 65 ++++++++++++++ 6 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 FuelBoard/RoadDistanceService.swift diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index a353e15..cedabd0 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -50,6 +50,21 @@ 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. + private func refreshRoadDistancesIfNeeded() { + guard let location else { return } + Task { + await RoadDistanceService.refreshIfNeeded( + stations: stations, + lat: location.lat, + lng: location.lng + ) } } @@ -377,6 +392,7 @@ struct ContentView: View { monitor.update(stations: stations, favourites: refreshedFavourites, fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) updateLiveActivity() + refreshRoadDistancesIfNeeded() } } ) @@ -949,7 +965,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/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..812ce35 --- /dev/null +++ b/FuelBoard/RoadDistanceService.swift @@ -0,0 +1,87 @@ +// 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 { + /// How many nearest stations to route per pass (bounds MKDirections calls). + static let candidatesPerPass = 12 + /// 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 nearest `candidatesPerPass` + /// stations around `lat`/`lng`. Throttled by time + distance; safe to call + /// on every location 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: nearest by straight-line (these are what widgets + // and the Live Activity show in-radius). + let nearest = stations + .sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) } + .prefix(candidatesPerPass) + + let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng) + var entries: [String: Double] = [:] + for station in nearest { + let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng) + if let meters = await roadMeters(from: origin, to: dest) { + entries[station.id] = meters + } + } + 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/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index 62fd5ab..34123de 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -620,3 +620,46 @@ final class OfflineDataLabelTests: XCTestCase { XCTAssertNil(FuelStore.offlineDataLabel(from: "not-a-date")) } } + +// MARK: - Road distance cache + +final class RoadDistanceCacheTests: XCTestCase { + 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": 3200]) + 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": 3200]) + let meters = FuelStore.roadDistanceMeters(for: "a", 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": 3200]) + // 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) + } +} 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 6b51ed5..dc05a2b 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -945,6 +945,62 @@ struct FuelStore { UserDefaults(suiteName: appGroupSuite)?.set(completed, forKey: onboardingCompletedKey) } + // 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 + + static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: Double]) { + 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. + static func roadDistanceMeters(for stationID: String, userLat: Double, userLng: Double) -> Double? { + guard let cache = loadRoadDistances(), + let meters = cache.entries[stationID] 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 } + return 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.id, userLat: userLat, userLng: userLng) { + return meters / 1000.0 + } + return station.distanceKM(to: userLat, lng2: userLng) + } + // MARK: Low-level keychain helpers private static func keychainData(service: String) -> Data? { @@ -1004,3 +1060,12 @@ struct FuelStore { loadString(service: "widget.diag.\(intentType)") } } + +/// Cached Apple-Maps road distances for nearby stations (see +/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres. +struct RoadDistanceCache: Codable { + let sourceLat: Double + let sourceLng: Double + let updatedAt: TimeInterval + let entries: [String: Double] +} From 90fb3b73857e09c7c1073daa5e139782804b8ea5 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Thu, 20 Aug 2026 11:54:23 +0100 Subject: [PATCH 10/21] road distance: cover all in-radius stations, not just nearest 12 Stations past the nearest-12 cutoff silently fell back to straight-line, which explained the remaining app-vs-Apple-Maps gaps (e.g. 3.5 straight-line vs 5.9 road). Raise the per-pass cap to 40 and route every station within a 25 km straight-line radius (covers the 15 mi max search window) so any station the widget/Live Activity/list can show gets a real road distance. Also harden the new cache tests against persistent keychain state across invocations (reset in setUp) so they pass deterministically. --- FuelBoard/RoadDistanceService.swift | 20 ++++++++++++------- .../FuelBoardSharedTests/FuelBoardTests.swift | 8 ++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/FuelBoard/RoadDistanceService.swift b/FuelBoard/RoadDistanceService.swift index 812ce35..2205770 100644 --- a/FuelBoard/RoadDistanceService.swift +++ b/FuelBoard/RoadDistanceService.swift @@ -15,17 +15,22 @@ import MapKit import WidgetKit enum RoadDistanceService { - /// How many nearest stations to route per pass (bounds MKDirections calls). - static let candidatesPerPass = 12 + /// 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 nearest `candidatesPerPass` - /// stations around `lat`/`lng`. Throttled by time + distance; safe to call - /// on every location fix. + /// 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 } @@ -38,11 +43,12 @@ enum RoadDistanceService { } } - // Candidate stations: nearest by straight-line (these are what widgets - // and the Live Activity show in-radius). + // 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: Double] = [:] diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index 34123de..d16c7b5 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -624,6 +624,14 @@ final class OfflineDataLabelTests: XCTestCase { // 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) From 10e3b54d0ca387c62785ee94f862ce0bc75e2446 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Thu, 20 Aug 2026 12:27:09 +0100 Subject: [PATCH 11/21] onboarding: persist completed flag keychain-first so it doesn't re-show The completed flag lived only in app-group UserDefaults (UserDefaults(suiteName: appGroupSuite)). On free SideStore accounts the app-group container isn't provisioned, so the suite read as nil: save was a silent no-op and load always returned false -> onboarding re-appeared on every launch even after finishing and granting all permissions. Switch to saveString/loadString (keychain-first, app-group mirror) under onboardingCompletedKey, matching favourites/distance-unit persistence which survives reinstall and works without a provisioning group. loadString falls back to the app-group mirror, so anyone who previously completed on a provisioned group keeps their flag; users on unprovisioned groups complete once more and then it sticks. --- Shared/FuelStore.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 6b51ed5..6c0cc94 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -934,15 +934,19 @@ 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: Low-level keychain helpers From d73a64023f4260a39648b0934f8bede8f3556eb6 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Thu, 20 Aug 2026 12:56:16 +0100 Subject: [PATCH 12/21] road distance: trigger at launch + foreground, fall back to saved location Road-distance routing previously only fired once a fresh GPS fix arrived via the location hook/onChange. A returning user on a cold launch waited for that fix, and if onboarding/flag issues kept tracking from starting, distances stayed straight-line. Now: - refreshRoadDistancesIfNeeded() uses the last saved location when the live fix isn't set yet, so it can run immediately. - It's called explicitly on launch (already-onboarded branch), on foreground activation, and on the existing location paths. --- FuelBoard/ContentView.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index cedabd0..1fca183 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -56,14 +56,16 @@ struct ContentView: View { /// 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. + /// 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() { - guard let location else { return } + let origin = location ?? FuelStore.loadLocation() + guard let origin else { return } Task { await RoadDistanceService.refreshIfNeeded( stations: stations, - lat: location.lat, - lng: location.lng + lat: origin.lat, + lng: origin.lng ) } } @@ -332,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 @@ -377,6 +383,7 @@ struct ContentView: View { monitor.update(stations: stations, favourites: refreshedFavourites, fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM) updateLiveActivity() + refreshRoadDistancesIfNeeded() // No network fetch on foreground — pull-to-refresh is the override. } else { locationManager.stopForegroundTracking() From 01517ba687c4889e5f063ddb051670b77cb35234 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Thu, 20 Aug 2026 13:12:53 +0100 Subject: [PATCH 13/21] onboarding: replay on a fresh/reinstalled install, not every launch The keychain-first completed flag persists across reinstalls (same bundle id), so a new install was treated as already-completed and onboarding silently skipped while permissions still prompted. Add install identity: - Store an install id in BOTH the app-own container defaults (wiped on reinstall, persists across launches) and keychain (survives reinstall). - isFreshInstall() is true on first-ever launch or when the local id is missing/different from keychain (i.e. reinstalled); it seeds a fresh common id so the next launch of the same install is not 'fresh' again. - shouldShowOnboarding() = not completed || fresh install -> onboarding now replays once per fresh/reinstall, never on ordinary launches. Works on free SideStore (no app-group), using the app's own defaults domain as the same-install signal. --- FuelBoard/ContentView.swift | 2 +- .../FuelBoardSharedTests/FuelBoardTests.swift | 12 ++++++ Shared/FuelStore.swift | 37 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 1fca183..07f98ae 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -321,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 diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index d16c7b5..86047a8 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -671,3 +671,15 @@ final class RoadDistanceCacheTests: XCTestCase { XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001) } } + +// 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/Shared/FuelStore.swift b/Shared/FuelStore.swift index eccb247..33f354c 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -949,6 +949,43 @@ struct FuelStore { 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 From 0b27d3d4f699eb71af69244340e2988b49375e44 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Thu, 20 Aug 2026 17:26:33 +0100 Subject: [PATCH 14/21] road distance: pin-fingerprint guard so embedded/live/cached never cross-pollute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The road-distance cache was keyed purely by station ID with no coordinate, so a value routed to one pin could be served for a same-ID station whose stored coordinate came from a different source (live fetch vs bundled offline dump vs a corrected pin), reproducing Maps mismatches. - RoadDistanceCache now stores each routed pin (CachedRoadDistance{meters, lat, lng}) and roadDistanceMeters() only serves a value when the displayed station's coordinate matches the pinned one (within ~11 m). - Kept the existing origin-distance staleness guard. - Verified embedded sample data (ids y1/se1/...) never collides with real relay IDs, and the widget STRICT radius filter drops far-offline samples, so no actual leak existed in practice — this closes the theoretical stale-pin channel and future-proofs against coordinate fixes. --- FuelBoard/RoadDistanceService.swift | 9 +++- .../FuelBoardSharedTests/FuelBoardTests.swift | 24 +++++++++-- Shared/FuelStore.swift | 42 +++++++++++++++---- 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/FuelBoard/RoadDistanceService.swift b/FuelBoard/RoadDistanceService.swift index 2205770..b2f8412 100644 --- a/FuelBoard/RoadDistanceService.swift +++ b/FuelBoard/RoadDistanceService.swift @@ -51,11 +51,16 @@ enum RoadDistanceService { .filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM } let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng) - var entries: [String: Double] = [:] + 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) { - entries[station.id] = meters + // 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 } diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index 86047a8..835342e 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -647,7 +647,8 @@ final class RoadDistanceCacheTests: XCTestCase { 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": 3200]) + 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) } @@ -655,8 +656,9 @@ final class RoadDistanceCacheTests: XCTestCase { 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": 3200]) - let meters = FuelStore.roadDistanceMeters(for: "a", userLat: 53.4808, userLng: -2.2426) + 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) @@ -664,12 +666,26 @@ final class RoadDistanceCacheTests: XCTestCase { } func testRoadDistanceUsedForOtherStationNotFound() { - FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1, entries: ["a": 3200]) + 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 diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index 33f354c..2ae5e16 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -999,7 +999,16 @@ struct FuelStore { /// user position before a cached road distance is treated as stale. static let roadDistanceOriginToleranceMeters: Double = 600 - static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: Double]) { + /// 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) { @@ -1017,10 +1026,11 @@ struct FuelStore { /// 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. - static func roadDistanceMeters(for stationID: String, userLat: Double, userLng: Double) -> Double? { + /// 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 meters = cache.entries[stationID] else { return nil } + 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 @@ -1030,13 +1040,18 @@ struct FuelStore { sin(dLng / 2) * sin(dLng / 2) let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a)) guard originDistanceMeters <= roadDistanceOriginToleranceMeters else { return nil } - return meters + // 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.id, userLat: userLat, userLng: userLng) { + if let meters = roadDistanceMeters(for: station, userLat: userLat, userLng: userLng) { return meters / 1000.0 } return station.distanceKM(to: userLat, lng2: userLng) @@ -1103,10 +1118,21 @@ struct FuelStore { } /// Cached Apple-Maps road distances for nearby stations (see -/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres. +/// 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: Double] + let entries: [String: CachedRoadDistance] } From 69c3c176063e6251e631b32bff2d1ba1dede7113 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Thu, 20 Aug 2026 17:54:55 +0100 Subject: [PATCH 15/21] trends: animate the price-history lines (subtle, Reduce-Motion aware) - Reloads (range/fuel switch, Price<->vs-cheapest) now glide the existing lines + y-axis to the new data via withAnimation/.animation(value: mode) instead of snapping. - One-time left->right trace-in on first data appearance by revealing an increasing prefix of each series' points (area + line stay in sync); later switches morph rather than re-trace. - Headline average rolls its digits with .contentTransition(.numericText). - All animation honours .accessibilityReduceMotion (jump-cut to final state). Kept two-pass area/line ordering, explicit series:, bounded fill bands, and tight chartYScale unchanged. --- FuelBoard/TrendsView.swift | 39 +++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/FuelBoard/TrendsView.swift b/FuelBoard/TrendsView.swift index b8303a5..c6b747c 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,11 @@ struct TrendsView: View { @State private var loadFailed = false @State private var firstSnapshot: String? + /// Lines trace-in left→right on first appearance by revealing an + /// increasing prefix of each series' points. Stays at the full count after + /// the first reveal so range/mode switches morph instead of re-tracing. + @State private var revealCount: Int = 0 + /// 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 +163,28 @@ struct TrendsView: View { // pointer probe already done above. loadFailed = firstSnapshot == nil } - series = fetched + // Morph vs trace-in: a reload (range/fuel switch) glides the existing + // lines to the new data; the first real draw traces each line in + // left→right. Reduce Motion jumps straight to the final state. + let hadData = hasAnyData + if hadData { + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.35)) { + series = fetched + } + } else { + series = fetched + let maxPoints = fetched.reduce(0) { max($0, $1.points.count) } + if maxPoints > 0 { + if reduceMotion { + revealCount = maxPoints + } else { + revealCount = 0 + withAnimation(.easeOut(duration: 0.5)) { + revealCount = maxPoints + } + } + } + } // 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). @@ -330,6 +357,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 +419,13 @@ struct TrendsView: View { // type-checker's budget.) Chart { ForEach(displaySeries) { history in - ForEach(history.points) { point in + ForEach(history.points.prefix(revealCount)) { 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(revealCount)) { point in lineMark(point, series: history.name, color: seriesColor(index(of: history.stationID))) } @@ -424,6 +454,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. From 508ccc38027203ebe6f6859fa481f6b31da2202d Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Thu, 20 Aug 2026 18:40:09 +0100 Subject: [PATCH 16/21] trends: staggered per-series line draw-in The single-waterline trace-in was invisible with sparse data (2 points draw in one step). Replace it with a per-series waterline: each station's line now traces left->right on first appearance, series revealed 0.18s apart in a cascade so the draw is clearly visible. Newly-added favourites get a full reveal immediately on reload; Reduce Motion still jump-cuts. Range/mode morphs and numeric headline transition unchanged. --- FuelBoard/TrendsView.swift | 42 +++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/FuelBoard/TrendsView.swift b/FuelBoard/TrendsView.swift index c6b747c..b91fee1 100644 --- a/FuelBoard/TrendsView.swift +++ b/FuelBoard/TrendsView.swift @@ -35,10 +35,12 @@ struct TrendsView: View { @State private var loadFailed = false @State private var firstSnapshot: String? - /// Lines trace-in left→right on first appearance by revealing an - /// increasing prefix of each series' points. Stays at the full count after - /// the first reveal so range/mode switches morph instead of re-tracing. - @State private var revealCount: Int = 0 + /// 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. @@ -171,16 +173,32 @@ struct TrendsView: View { withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.35)) { series = fetched } + // Newly-added favourites have no reveal entry yet — surface them + // fully so their line isn't left blank by the draw-in waterline. + for h in fetched where revealed[h.stationID] == nil { + revealed[h.stationID] = h.points.count + } } else { series = fetched - let maxPoints = fetched.reduce(0) { max($0, $1.points.count) } - if maxPoints > 0 { + let hasPoints = fetched.contains { !$0.points.isEmpty } + if hasPoints { if reduceMotion { - revealCount = maxPoints + var all: [String: Int] = [:] + for h in fetched { all[h.stationID] = h.points.count } + revealed = all } else { - revealCount = 0 - withAnimation(.easeOut(duration: 0.5)) { - revealCount = maxPoints + revealed = [:] + let cascadeNS = UInt64(0.18 * 1_000_000_000) + for (i, h) in fetched.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 + } + } } } } @@ -419,13 +437,13 @@ struct TrendsView: View { // type-checker's budget.) Chart { ForEach(displaySeries) { history in - ForEach(history.points.prefix(revealCount)) { 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.prefix(revealCount)) { point in + ForEach(history.points.prefix(revealed[history.stationID] ?? 0)) { point in lineMark(point, series: history.name, color: seriesColor(index(of: history.stationID))) } From 9342ccc7c946eff9ee0558a1b360619bba06fbbe Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Thu, 20 Aug 2026 18:49:54 +0100 Subject: [PATCH 17/21] trends: redraw lines on 7/30/90 selection Callers previously morphed the existing lines on a reload, so picking a new range only glided in place. Now every range/fuel selection (the only reloads; the sheet has no periodic refetch) replays the staggered per-series draw-in via revealSeries(), so the lines visibly redraw left->right when the user switches 7/30/90. Price<->vs-cheapest still morphs (pure view toggle, no reload). Reduce Motion jump-cuts. --- FuelBoard/TrendsView.swift | 71 ++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 38 deletions(-) diff --git a/FuelBoard/TrendsView.swift b/FuelBoard/TrendsView.swift index b91fee1..33e867e 100644 --- a/FuelBoard/TrendsView.swift +++ b/FuelBoard/TrendsView.swift @@ -165,44 +165,13 @@ struct TrendsView: View { // pointer probe already done above. loadFailed = firstSnapshot == nil } - // Morph vs trace-in: a reload (range/fuel switch) glides the existing - // lines to the new data; the first real draw traces each line in - // left→right. Reduce Motion jumps straight to the final state. - let hadData = hasAnyData - if hadData { - withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.35)) { - series = fetched - } - // Newly-added favourites have no reveal entry yet — surface them - // fully so their line isn't left blank by the draw-in waterline. - for h in fetched where revealed[h.stationID] == nil { - revealed[h.stationID] = h.points.count - } - } else { - series = fetched - let hasPoints = fetched.contains { !$0.points.isEmpty } - if hasPoints { - if reduceMotion { - var all: [String: Int] = [:] - for h in fetched { all[h.stationID] = h.points.count } - revealed = all - } else { - revealed = [:] - let cascadeNS = UInt64(0.18 * 1_000_000_000) - for (i, h) in fetched.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 - } - } - } - } - } - } + // 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). @@ -213,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: From ed41df46dece804fa906076176520115d081536c Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Thu, 20 Aug 2026 19:27:34 +0100 Subject: [PATCH 18/21] siri: drop generic shortcut and broaden fuel-word phrases Siri was hit-and-miss on 'unleaded near me' (\u2018can't do that, searching in app\u2019 fallback). The generic parameterized AppShortcut ('cheapest ${fuel}') competed with the fixed-fuel literal-word shortcuts for the same intent, degrading NLU ranking; it was already documented as flaky. Remove it (now 9 shortcuts, under the 10 cap) and add natural phrase variants for unleaded/petrol ('What's the cheapest unleaded near me', 'Cheapest unleaded near me', 'Where's the cheapest petrol'). All phrases keep .applicationName per the iOS 26 metadata requirement. Build-verified in extract.actionsdata: generic gone, new phrases registered. --- FuelBoard/SiriShortcuts.swift | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/FuelBoard/SiriShortcuts.swift b/FuelBoard/SiriShortcuts.swift index a2012fa..36809af 100644 --- a/FuelBoard/SiriShortcuts.swift +++ b/FuelBoard/SiriShortcuts.swift @@ -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" From 98cf3a5736aed61645c79cfd50bde2cf8be4377c Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Fri, 28 Aug 2026 16:35:32 +0100 Subject: [PATCH 19/21] Git: ignore build-watch output --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/ From a71b7ae20ebd6586562098cb8c9e3c6bc88d58d5 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Fri, 28 Aug 2026 17:48:51 +0100 Subject: [PATCH 20/21] Siri: remove Apple from App Intent descriptions --- FuelBoard/SiriShortcuts.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/FuelBoard/SiriShortcuts.swift b/FuelBoard/SiriShortcuts.swift index 36809af..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 From 50036c8a6711465bcf53c3d1afd30d4bb9030332 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Fri, 28 Aug 2026 19:00:43 +0100 Subject: [PATCH 21/21] Signing: set FuelBoard team for app and widget --- FuelBoard.xcodeproj/project.pbxproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/FuelBoard.xcodeproj/project.pbxproj b/FuelBoard.xcodeproj/project.pbxproj index 2d09c61..e4ec4d9 100644 --- a/FuelBoard.xcodeproj/project.pbxproj +++ b/FuelBoard.xcodeproj/project.pbxproj @@ -292,6 +292,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 = ( @@ -314,6 +315,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 = ( @@ -336,6 +338,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 = ( @@ -360,6 +363,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 = (