Compare commits

..
Author SHA1 Message Date
FuelBoard Contributor ab93f6286a Add the Key button to the Favourites tab
Favourites gains the same info button the Stations list has, last in
the trailing toolbar group so it sits to the right of Edit. It stays
visible when the list is empty, because the legend explains the prices
and labels before anything is starred — Trends and Edit still hide
with no favourites.

The legend itself is now one view, KeySheetView, lifted out of
StationsView's private computed property so both tabs present the
same sheet and cannot drift apart. It lives in StationsView.swift
because the key's row types are file-private there, which keeps the
Xcode project untouched. Stations passes its selected fuel's tint and
Favourites its active tab's, so the station-label pump badge matches
the rows on screen; everything else — detents, the two nested sheets,
and the -showKeySheet / -showBestValueInfo / -showImportantInfo QA
launch hooks — behaves exactly as before.
2026-10-18 20:16:30 +01:00
FuelBoard Contributor 6edab35400 Give the closest capsule the rounded rect whenever it wraps
The capsule's shape was keyed to the verdict's declared line count, so
"Closest station" — one declared line — always drew as a Capsule while
its text wrapped to two lines in the row's narrow column, leaving the
semicircular ends crowding the words they wrapped. Saves never showed
this because its amount line makes the declared count two.

The shape now follows the DRAWN height: a GeometryReader behind the
text block reports what it actually rendered, divided by the caption's
line height taken through ScaledMetric so the test holds at every
Dynamic Type size. One drawn line keeps the capsule; two or more get
the same 8pt rounded rect the other verdicts use. The first layout
pass, before there is a height, falls back to the declared count.
2026-10-18 20:16:30 +01:00
FuelBoard Contributor 244f9be640 Refine the station listing: muted verdict capsules, three-line names
The listing's verdict capsule now carries white text on muted semantic
fills instead of the neutral secondary tint: Saves #45825E, Costs
#A9635B, Break even #707070, Closest #58789D.

Fixed tones, not translucent tints, because white text on a 15% tint
over a white row measures ~1.8:1. Each tone sits at a relative
luminance of ~0.18 — the band where white clears 4.5:1 AND the pill
still separates ~3:1 from both a white row and the dark card fill — so
one value each works in light and dark without a dynamic colour. The
saturated palette colours cannot carry white text at all: systemGreen
measures 2.2:1, systemRed 3.6:1.

"Save" becomes "Saves", matching the detail sheet and reading as a
statement of the outcome; amounts, symbols, padding, radius and the
shape-follows-line-count rule are unchanged. Saves and Costs share an
84pt minimum width so their trailing edges line up down the list.

The price and its capsule are now one vertically grouped element (5pt),
with the badge keeping the column's full 12pt above, so the row no
longer reads as three equal lines.

Station names cap at three lines with a tail ellipsis: unbounded wrap
let one very long forecourt name set the height of the whole row. 59 of
the 8,073 names in the dump exceed three lines and now ellipsise; the
full name is always in Station Details. Addresses still wrap freely.

The baseline pill moves onto ListingFuelSavingsCapsule so the listing
shows the same soft blue and white text as its siblings, leaving
FuelSavingsCapsule to the detail sheet, which it now alone uses.
Badges keep their tinted styling: they annotate a price rather than
report an outcome.

Verified: 261/261 swift test; Debug simulator and Release device
builds; all four fills and their white text measured from simulator
pixels in both appearances, and the detail sheet's .blue absent from
the listing entirely.
2026-10-18 19:45:19 +01:00
FuelBoard Contributor c46a7748e6 Show the saving in the listing capsule on its own line
Station and Favourite rows share one capsule, so both now read
"Save / £3.10" and "Costs / £1.20 more" instead of the amount-free
"Saves" / "Costs more". "(approx.)" is appended on its own line when the
journey behind the figure was estimated. Break even carries no figure —
±£0.05 is noise — so it stays a single line unless the qualifier adds one.

- Split `listingCapsuleHeadline()` into a structured `ListingVerdict`
  (headline / amount / qualifier) drawn as one Text per line, so where the
  capsule breaks is decided by the data rather than by the width the price
  column happened to hand it: the figure can never straddle two lines.
- The capsule's shape follows its line count: one line stays a Capsule, two
  or more switch to a rounded rect at 8pt, whose corners do not crowd a
  taller block the way a pill's semicircular ends do.
- The Key sheet's legend renders the same component with `showsAmount:
  false`, so it demonstrates the wording without displaying the placeholder
  £1.00 figure it was built from.
- The new words ("Save", "Costs", "(approx.)", "%@ more") go into
  Localizable.strings so the copy editor owns them; amounts run through
  FuelSavingsFormat.money and the amount line is monospaced.
- VoiceOver keeps the single-line form, since a drawn break is a pause
  mid-phrase when spoken.

Tests cover the line structure for all four statuses, the estimated
qualifier and the amount-free legend. Pixel measurement on the simulator
confirms the shape rule: one line measures a radius of h/2 (a pill), two
lines measure 8pt, in both light and dark.
2026-10-18 18:25:59 +01:00
FuelBoard Contributor 6cbb083208 Retire the Best Value proximity alert option; move Key sheet copy into a tested model
- Alerts: drop the alert-type picker and the Best Value alert path, so
  proximity alerts always follow the cheapest station. A stored `bestValue`
  selection now falls back to cheapest when loaded.
- Key sheet: the price-rating, savings, station-label and favourite wording
  comes from `KeySheetContent` in Shared/FuelSavings.swift — a Foundation-only
  model with tests that pin the exact meaning of each indicator — and the
  sheet is built from that model rather than inline strings.
- BACKLOG: record the plan to restore Best Value proximity alerts behind one
  shared evaluator (user-location baseline, a single disclosed qualification
  rule, and real/Test alert parity).
- Bump CURRENT_PROJECT_VERSION to 29.
2026-10-18 17:16:12 +01:00
FuelBoard Contributor c331f3d663 Add a reusable Important information disclaimer sheet
One sheet, reachable from the bottom of the Key sheet and from the bottom
of Settings, with each topic as its own card and heading:

- Fuel Prices, Fuel Availability, Savings & Best Value, Journey Estimates,
  Before You Travel and Disclaimer, in that order.
- Every heading carries an SF Symbol in a fixed-width, Dynamic-Type-aware
  slot, so heading text lines up across sections regardless of glyph width.
  Icons take the accent colour, headings are semibold and primary, and each
  description sits beneath its heading in secondary text.
- All copy lives in en.lproj/Localizable.strings keyed by the English
  literal, ready for the web copy editor.
- The sheet, its trigger row and the .importantInformationSheet(isPresented:)
  modifier live in one file, so any further entry point is a one-line change
  and the wording cannot drift between them.
- DEBUG-only `-showImportantInfo` launch hook, chained behind the Key sheet,
  for screenshotting without a tap.
2026-10-18 17:16:07 +01:00
FuelBoard Contributor 7aa2485994 Move detail approximation after verdict amount 2026-10-17 20:24:21 +01:00
FuelBoard Contributor c6e252d194 Simplify station financial verdicts 2026-10-17 19:55:55 +01:00
FuelBoard Contributor 7aaaffa1c5 Revert station capsule layout changes 2026-10-17 18:31:45 +01:00
FuelBoard Contributor 7111ce38af Unify disabled states and preserve station verdicts 2026-10-17 18:22:55 +01:00
FuelBoard Contributor ff6cbbe07d Align Settings dependent disabled states 2026-10-17 14:40:44 +01:00
FuelBoard Contributor f261b0b10a Remove redundant comparison mode notice 2026-10-17 14:15:54 +01:00
FuelBoard Contributor 1b6184c035 Simplify shared fill-up wording and help 2026-10-17 14:03:45 +01:00
FuelBoard Contributor 6a9b5df4f1 Alerts tab: disable option rows when their setting is off 2026-10-17 13:41:11 +01:00
FuelBoard Contributor 1cd4d86a69 Alerts tab: clearer copy, dynamic descriptions, section rename 2026-10-17 12:18:54 +01:00
FuelBoard Contributor d2143d255f Settings: keep the Replay onboarding description inside its card
The Replay onboarding section rendered its description as a section footer, so
the text sat below the card while every other control card shows its description
inside the card above a hairline. Move the same copy into the section body as a
footnote/secondary row. Wording, the replay action and its behaviour are
unchanged; only the Fuel Savings general explanation and the Support tip note
remain section footers.
2026-10-17 11:33:13 +01:00
FuelBoard Contributor 31eb441d0e Settings: keep the Tap action description inside its card
The Tap action section rendered its description as a section footer, so the
text sat below the card while Units, Distances and Data checking show their
descriptions inside the card, separated from the control above by a hairline.
Move the same copy into the section body as a footnote/secondary row so the
card matches its neighbours. Wording is unchanged and no behavioural or
persistence code is touched.
2026-10-17 11:23:12 +01:00
FuelBoard Contributor e5a2b2ebdb Settings: concise copy and tighter hierarchy
Rewrite the Settings screen in short British English and remove the
duplicated paragraphs, keeping every control, binding and preference key as
it was.

- Fuel Savings: one-line descriptions for Best Value, average fuel economy,
  fill-up amount (worded for the unit in use) and minimum saving; one concise
  general explanation replaces the long footer; the orange "New comparison
  basis" notice is removed.
- Units: "Station sign" becomes "Pence per litre"; the combined paragraph
  becomes two contextual descriptions.
- Distances: "Accurate distances" becomes "Driving distances" with a short
  description; the route-request and search-radii paragraph is removed.
- Tap action: "Open map"/"More info" become "Directions"/"Station details"
  with a short description.
- Data checking: Standard and Smart summaries cut to one line each, one shared
  background-update note, duplicated footer removed.
- Replay onboarding: shorter description. Support FuelBoard untouched.

Copy and layout only: no calculations, ranking, refresh schedules, stored
values or persistence changed. New and reworded literals live in
FuelBoard/en.lproj/Localizable.strings.
2026-10-17 10:50:59 +01:00
FuelBoard Contributor d2d4179ab6 Settings sliders: commit on release instead of writing on every step
Both sliders now move a local draft (mpgDraft / fillUpDraft) and write once,
when the drag ends, via Slider's onEditingChanged. While the thumb is down only
this screen redraws: no store write, no fuelSavingsSettings mutation, and so no
app-wide recompute — ContentView re-ran its body, rebuilt the savings lookup,
and passed all 8,130 stations through the alert monitor on every step.

The drafts are Double?, where nil means "not dragging" and the stored setting is
shown. That falls back naturally after a commit, so the control shows what was
actually saved (the clamps can lower it) and anything changing the setting from
outside — a unit switch, the in-list control, a QA hook — is reflected without
any re-seeding.

The MPG and fill-up read-outs follow the draft, so the figure still tracks the
thumb.

The keychain write deliberately stays on the main thread: it is the
authoritative mirror on read, so an async write could hand a relaunch or a
widget a stale value. One write per gesture is cheap enough not to need it.
2026-10-16 20:33:10 +01:00
FuelBoard Contributor 965a506a43 Store: save one Fuel Savings field per edit, and update keychain items in place
The Settings sliders called persistFuelSavingsSettings() on every value change,
which rewrote all four keys — four UserDefaults writes and four keychain round
trips per step, on the main thread, up to 90 times per drag. That is what made
them stutter.

saveFuelSavingsField(_:from:) writes just the field that moved, so a slider
gesture touches one key.

writeKeychain now uses SecItemUpdate and only falls back to SecItemAdd on
errSecItemNotFound. Delete-then-add cost two round trips to securityd and
briefly left the key holding no value at all — a window where the keychain, the
authoritative mirror on read, could disagree with the app-group defaults.

Tests pin the store half: a single-field save leaves the other three keys alone,
and the whole-settings save still writes all four.
2026-10-16 20:33:10 +01:00
FuelBoard Contributor 6ed4fe1210 How Best Value works: a Setup Best Value button, and a clean hand-off to Settings
The explanation sheet now carries a full-width button that closes it and the
sheet it was opened from, then lands on Settings — whose Best Value section is
the first thing in its list, so the controls are on screen on arrival. It uses
.controlSize(.large), the standard iOS button height.

Both the More info sheet and the Key sheet are presented by StationsView, and
FavouritesView presents its own More info sheet — not ContentView's monitor as
assumed — so each host closes whichever sheet it owns on the notification.
Before this, one of them stayed up over the Settings tab. The button now
announces itself half a second after its own dismissal, so the child and parent
dismissals cannot race and drop one of the two.
2026-10-16 20:06:48 +01:00
FuelBoard Contributor 9700e5bcb1 Station detail: one verdict capsule, the chosen price style, and the working explained
The Best Value card states its verdict once. The badge is now the large capsule form the sheet used to repeat on its own line, so the same headline no longer appears twice, and the comparison caption ("vs closest station" / "your baseline") sits under the capsule — on the card and on the listing rows alike. The row's size-class split, which put the caption beside the capsule at regular width, is gone, so the pair reads the same on every device.

The facts row's price now follows Settings -> Units -> Price display. It called FuelStore.priceText(price, style: .stationSign) with the style hardcoded, so a Pounds & pence user still saw the bare forecourt figure under the map. It now uses priceTextAttributed with the style passed down from the list that opened the sheet (ContentView, StationsView, FavouritesView) — the same call the listing row makes, at the same @ScaledMetric(relativeTo: .title2) 22pt so it still scales with Dynamic Type.

"How Best Value works" gains a step-by-step section in plain English: the baseline and why it is the closest station by journey, the return trip and why the extra return travel can read larger than the one-way distance, the extra driving cost, the fuel-price saving, the total and its inversion when the drive costs more than the price saves, and where the minimum saving is set. Every statement matches the arithmetic in FuelSavingsCalculator. The sheet opens at 72% instead of 42% to give it room.

No calculation, ranking, wording or data source changed: comparisonLabel, priceTextAttributed and the shared FuelSavingsResult are the same calls they were.
2026-10-16 17:15:11 +01:00
FuelBoard Contributor 05c76f21a9 Redesign the More info sheet
The sheet leads with the station name and address inside the card, above the map, then a two-column facts row (fuel price on the left, distance and return on the right, split by a hairline) and the two station actions divided the same way. The Best Value block becomes a tinted card: verdict badge, the two figures the estimate is made of, the working and the total. "How Best Value works" moves to its own row below it, using the shared HowBestValueRow the Key sheet already shows.

Presentation only: every figure still comes from the shared sources (FuelStore.priceText, stationDistanceDisplay, FuelSavingsResult), so the sheet cannot disagree with the listing row, the widget or the Live Activity, and no calculation, ranking or freshness rule changed.

StationDistanceDisplay.returnLabel(unit:) and FuelSavingsFormat.extraTravelDistanceLabel(...) are split out of their caption forms so a surface printing the return or extra-travel figure on its own line takes it from the same place, confidence marker included; their existing output is unchanged, pinned by tests. FuelSavingsSummaryView is deleted - the sheet's card supersedes it and nothing referenced it.

DEBUG-only QA hooks: -qaConfigureSavings configures a Fuel Savings plan (the Best Value card and the Worth the Drive sort are gated on it), -qaDetailBottom scrolls to that card (defaultScrollAnchor is a no-op on a List) and -qaCopyPostcode holds the copied confirmation long enough to screenshot.
2026-10-16 14:51:36 +01:00
FuelBoard Contributor ff8a1e18d0 Redesign the station listing and move the Best Value controls
Two columns split by a 1pt hairline: identity on the left at a fixed 52%
share so rows line up when scrolling, price and savings on the right,
leading-aligned on one axis with the price dominant. The favourites star
sits under the station logo, freeing the width the pounds-and-pence price
needs to render in full rather than scaled or truncated.

Badges stay tinted rather than filled — BEST VALUE green, CHEAPEST blue,
none for nearest — and the verdict capsule is one shared view across the
listing, the detail sheet and the compact summary, so wording and colour
cannot drift between them.

Best Value's minimum saving moves from Alerts to Settings, Fuel Savings,
beside the fill size and MPG that produce the saving; the Alerts tab reads
it back instead of offering it twice. Its options are now 50p steps to £5
(was 50p/£1/£2/£5), so £2.50 and £3 are reachable, with a test pinning the
step, the ceiling and the store's string round-trip.

The verdict capsule scales to 0.7 before truncating. Measured on a 393pt
screen its copy is handed ~69pt while "Closest station" needs ~90pt, so the
old 0.8 floor could not reach it and rows showed "Closest stati..." instead.

Verified: Debug build clean, swift test 249/249.
2026-10-16 13:38:11 +01:00
FuelBoard Contributor e960ef85c6 Make station price age informational only
The relay's price_updated is the maximum price_last_updated across the grades it maps, so it cannot vouch for the selected grade: stop using it to score, gate or warn about Best Value.

- FuelPriceFreshness is now station-level and display-only: one age per station, fresh/outdated/unknown/invalid, "May be outdated" past 30 days, with future-tolerance and non-finite guards.
- Best Value restores the closest-station-selling-the-selected-fuel baseline and its savings ranking; eligibility gating, eligible-first sorting and the unavailable banner are gone.
- Price age appears in the More info sheet only - no listing-row warnings, no extra banners.
- Copy reverted from "closest eligible station" to "closest station"; stale price-confidence strings removed, "May be outdated" added.
- Dormant per-grade timestamp plumbing removed, and regression tests added for every fuel type, missing/invalid/future stamps and the bundled offline dump.
2026-10-16 09:48:01 +01:00
FuelBoard Contributor 5900a81473 Station detail: price above its age, and the verdict caption on its own line
- More info lists the fuel price first, with "Price updated" beneath it:
  the price is what the row is about, the age qualifies it.
- The detail sheet stacks "vs closest eligible station" under the verdict
  capsule, matching the listing rows, so the caption no longer shares a
  line with the verdict it describes.
2026-10-15 22:48:07 +01:00
FuelBoard Contributor ed3a5137cd Best Value rows: capsule on its own line, comparison caption beneath
The verdict capsule shared a line with "vs closest eligible station"; the
caption's layoutPriority squeezed the capsule until it truncated
("Approx. £2.75 better off after tra…"), and in this sort mode the verdict
is the point of the row.

- The capsule now owns the line and the caption sits under it.
- The capsule text also shrinks rather than truncating at large Dynamic
  Type sizes, since nothing competes with it for width.
- The detail sheet keeps its side-by-side layout: it has the room, and the
  verdict there is the sheet's headline.
2026-10-15 22:25:53 +01:00
FuelBoard Contributor 5f3051aed7 Best Value: one price age instead of a source breakdown
Dated pricing had crowded More info and the station rows, and most of the
extra copy described a distinction the data cannot make: every station
carries a single station-wide timestamp, so "grade-specific vs
station-wide" never varied in practice.

- More info shows one row, "Price updated 28h ago" (or "Not provided"),
  replacing the separate station-report and fuel-update-time rows and the
  blanket "price freshness is not confirmed" label.
- Rows and the detail sheet stay quiet when a saving is shown; only an
  ineligible comparison explains itself. Provisional savings already read
  as estimates from the capsule, so the fuel-named warning lines are gone.
- Freshness/eligibility logic is unchanged: it still gates the baseline and
  still supports grade-specific timestamps for when the relay emits them.
- Tests updated; localisation keys for the removed copy dropped.
2026-10-15 22:00:48 +01:00
FuelBoard Contributor 5c0458dbf6 Drop deprecated MapKit and ActivityKit calls, and a dead capture
- RoadDistanceService builds route endpoints with MKMapItem(location:address:)
  instead of the MKPlacemark path deprecated in iOS 26.
- LiveActivityManager updates through ActivityContent, replacing the
  ActivityKit update(using:) call deprecated in iOS 16.2.
- The location-update hook no longer captures an unused state object; the
  closure body already runs on self, so behaviour is unchanged.
2026-10-15 21:37:41 +01:00
FuelBoard Contributor 9e77ee2947 Best Value: explain price age per surface instead of one blanket warning
- Station detail now separates the station's own report age from the
  selected fuel's update time, so a recent station-wide report no longer
  reads as an old price.
- Warning copy names the fuel and says which side is weak: the station's
  price is not individually dated, or the baseline's is. The misleading
  "unverified or older than 7 days" line is gone.
- Wording stays "Closest" throughout; new copy uses Unleaded / Premium /
  Diesel rather than the internal grade codes.
- Localisation: new and updated keys for the added copy.
2026-10-15 21:37:35 +01:00
FuelBoard Contributor 7c2eeb2604 Best Value: baseline on the closest eligible price and gate on freshness
- Add FuelPriceFreshness: selected-fuel-aware eligibility plus a shared
  comparison confidence, so a saving is presented as verified only when
  both the candidate and the baseline pass the gate for the fuel on screen.
- Rank against the closest station with an eligible selected-fuel price
  instead of the physically closest station, so a single stale neighbour
  can no longer invalidate every comparison in the list. Ordinary Closest
  sorting still includes every priced station.
- Keep ineligible prices visible, fall back to pump-price order, and label
  Best Value unavailable when no comparison qualifies.
- Widget: same baseline and confidence path as the app.
- Tests: stale-closest baseline, alert baseline, eligibility policy,
  boundary and missing-timestamp regressions.
2026-10-15 21:37:24 +01:00
FuelBoard Contributor 39a97f4905 Standardise onboarding header sizes 2026-10-15 17:23:25 +01:00
FuelBoard Contributor 3819b8a6ab Add Best Value small widget and standardise Closest wording 2026-10-15 17:14:02 +01:00
FuelBoard Contributor a5688cbac2 Clarify fuel savings comparison labels 2026-10-15 11:38:24 +01:00
39 changed files with 3606 additions and 858 deletions
+18
View File
@@ -106,6 +106,24 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
scene (CPListTemplate) for navigation deep-links.
- [ ] **Alert region tuning** — make alert trigger radius + dedup window
user-configurable (currently fixed 300 m trigger / 1 per station per hour).
- [ ] **Restore Best Value proximity alerts after fixing evaluation parity** —
the option is intentionally removed from the Alerts tab and legacy selections
fall back to `Cheapest station` until this is implemented. Root cause: the live
geofence path calculates journeys from the entered station, making that station
its own zero-saving baseline and therefore unable to clear any positive minimum
saving, while Test alert calculates from the user's location and can pass.
Future implementation must obtain a fresh user fix on region entry; fetch,
radius-filter, choose the closest eligible baseline and calculate every journey
from that user location; then notify only when the entered station is the best
qualifying candidate. Real and Test alerts must call one shared pure evaluator.
Apply one disclosed qualification rule everywhere (real alert, Test alert and
BEST VALUE badge): remove the live path's hidden extra £0.50 estimate margin,
compare the same penny-rounded amount shown to the user while retaining raw
precision for ranking, and pass the current threshold explicitly into station
rows instead of reading persistence per row. Regression coverage: entered
station can qualify, a better nearby candidate blocks it, real/Test parity,
exact and display-rounding boundaries, estimated/confirmed parity, immediate
threshold changes, and below-threshold rejection.
- [ ] **Price-drop alerts: threshold control + Check now** — keep the current
cache-first / 12 h refresh policy, but add a per-alert sensitivity control
for favourite price-drop alerts instead of a fetch-frequency slider. Scope:
+8 -8
View File
@@ -533,7 +533,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 29;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -599,7 +599,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 29;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -663,7 +663,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 29;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -728,7 +728,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 29;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -826,7 +826,7 @@
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 29;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/App-Info.plist";
@@ -851,7 +851,7 @@
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 29;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/App-Info.plist";
@@ -875,7 +875,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 29;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/Widget-Info.plist";
@@ -900,7 +900,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 29;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/Widget-Info.plist";
+110 -62
View File
@@ -6,15 +6,12 @@ import SwiftUI
/// versa). The Live Activity section also lives here — it mirrors this page's
/// shape (toggle + fuel + distance) so the two "notify me while driving"
/// surfaces sit together. Geofence plumbing (count, 18-limit) lives in
/// Settings → Debug; the user-facing page keeps a plain-language explainer
/// plus a 0-stations hint.
/// Settings → Debug; the user-facing page keeps short descriptions of the
/// cheapest-station rule and radius, plus a 0-stations hint.
struct AlertsView: View {
@Binding var enabled: Bool
@Binding var radius: Double // stored in km (monitor + storage)
@Binding var fuel: FuelType // the fuel alerts monitor for
@Binding var alertMode: AlertMode
@Binding var bestValueThresholdGBP: Double
let bestValueConfigured: Bool
@Binding var favouriteDropEnabled: Bool
@Binding var favouriteDropFuel: FuelType
/// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
@@ -52,31 +49,29 @@ struct AlertsView: View {
return String(format: String(localized: "%lld %@", comment: "Distance label"), shown, distanceUnit.label(for: Double(shown)))
}
/// Situational guidance for the currently-selected alert radius, shown
/// under the picker so users know what each choice is best for. Labels
/// mirror the picker row text (unit-aware) and explain the trade-off:
/// smaller radii ping more often but later; larger radii ping rarely in
/// town but earlier from afar.
private var alertRadiusGuidance: String {
/// The distance the geofence actually caps "Follow search" at, in miles
/// (FuelStore stores the cap in km so the monitor and storage are unit-free).
private var followSearchCapMiles: Int {
Int((FuelStore.alertFollowCapKM / FuelSavingsCalculator.kilometresPerMile).rounded())
}
/// The radius the selected option really monitors, stated in the user's
/// unit — e.g. "Get alerts when you're within 2 miles of a matching
/// station." "Follow search" mirrors the Stations-tab distance, which the
/// geofence caps; said plainly, and only when the search distance really
/// exceeds the cap, so the sentence never contradicts the picker row.
private var alertRadiusDescription: String {
switch alertChoice.wrappedValue {
case .followSearch:
return String(localized: "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing).", comment: "Alert radius guidance for following search distance")
case .fixed(let value):
let optionLabel = String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))
let body: String
switch value {
case 1:
body = String(localized: "Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips.", comment: "Alert radius guidance")
case 2:
body = String(localized: "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town.", comment: "Alert radius guidance")
case 3:
body = String(localized: "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns.", comment: "Alert radius guidance")
case 5:
body = String(localized: "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle.", comment: "Alert radius guidance")
default:
body = String(localized: "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires.", comment: "Alert radius guidance")
let capped = stationLimit > followSearchCapMiles
let label = distanceLabel(forMiles: capped ? followSearchCapMiles : stationLimit)
if capped {
return String(format: String(localized: "Get alerts when you're within %@ of a matching station (your search distance is capped for reliable alerts).", comment: "Proximity alert radius description, follow search capped"), label)
}
return String(format: String(localized: "%@ — %@", comment: "Alert radius guidance with option label"), optionLabel, body)
return String(format: String(localized: "Get alerts when you're within %@ of a matching station.", comment: "Proximity alert radius description"), label)
case .fixed(let value):
let label = String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))
return String(format: String(localized: "Get alerts when you're within %@ of a matching station.", comment: "Proximity alert radius description"), label)
}
}
@@ -133,74 +128,110 @@ struct AlertsView: View {
)
}
/// The radius currently selected, in the user's unit — shown by the disabled
/// row in place of the picker.
private var alertRadiusValueLabel: String {
switch alertChoice.wrappedValue {
case .followSearch:
return String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)
case .fixed(let value):
return String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))
}
}
private var laDistanceValueLabel: String {
switch laChoice.wrappedValue {
case .followSearch:
return String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)
case .fixed(let miles):
return distanceLabel(forMiles: miles)
}
}
/// An option row that belongs to a toggle. When the toggle is off the control
/// is replaced by a static value row. The enclosing dependent group applies
/// FuelBoard's shared disabled interaction and appearance once.
@ViewBuilder
private func optionRow<Content: View>(_ title: String,
value: String,
isEnabled: Bool,
@ViewBuilder picker: () -> Content) -> some View {
if isEnabled {
picker()
} else {
HStack {
Text(title)
Spacer()
Text(value)
}
}
}
var body: some View {
NavigationStack {
List {
Section {
Toggle(String(localized: "Cheapest favourite", comment: "Favourite price-drop alert toggle"), isOn: $favouriteDropEnabled)
Group {
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
value: favouriteDropFuel.displayName,
isEnabled: favouriteDropEnabled) {
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $favouriteDropFuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
}
}
}
Button(String(localized: "Check now", comment: "Manual favourite price-drop check button"), action: onCheckFavouriteDropNow)
.disabled(!favouriteDropEnabled)
Text(String(localized: "Get notified when a different favourite becomes the cheapest, or your cheapest favourite drops in price.", comment: "Favourite price alerts section description"))
.font(.footnote)
.foregroundStyle(.secondary)
}
.fuelBoardDisabledState(isEnabled: favouriteDropEnabled)
} header: {
Text(String(localized: "Price-drop alerts", comment: "Favourite price-drop alerts section title"))
} footer: {
Text(String(localized: "Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise.", comment: "Favourite price-drop alerts section footer"))
Text(String(localized: "Favourite price alerts", comment: "Favourite price alerts section title"))
}
Section {
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
Picker(String(localized: "Alert type", comment: "Proximity alert type picker title"), selection: $alertMode) {
ForEach(AlertMode.allCases.filter { mode in mode != .bestValue || bestValueConfigured }) { mode in
Text(mode.displayName).tag(mode)
}
}
if !bestValueConfigured {
Text(String(localized: "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.", comment: "Best Value alert unavailable hint"))
.font(.caption)
Group {
Text(String(localized: "Get notified when you're near one of the cheapest stations for your chosen fuel.", comment: "Cheapest station proximity alert description"))
.font(.footnote)
.foregroundStyle(.secondary)
}
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
value: fuel.displayName,
isEnabled: enabled) {
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $fuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
}
}
}
optionRow(String(localized: "Radius", comment: "Alert radius picker title"),
value: alertRadiusValueLabel,
isEnabled: enabled) {
Picker(String(localized: "Radius", comment: "Alert radius picker title"), selection: alertChoice) {
Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(AlertRadiusChoice.followSearch)
ForEach(FuelStore.alertRadiusOptions, id: \.self) { value in
Text(String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))).tag(AlertRadiusChoice.fixed(value))
}
}
Text(alertRadiusGuidance)
.font(.caption)
}
Text(alertRadiusDescription)
.font(.footnote)
.foregroundStyle(.secondary)
if alertMode == .bestValue {
Picker(String(localized: "Minimum saving", comment: "Best Value alert threshold picker title"), selection: $bestValueThresholdGBP) {
ForEach(FuelStore.bestValueAlertThresholdOptionsGBP, id: \.self) { value in
Text(FuelSavingsFormat.money(value)).tag(value)
}
}
if !bestValueConfigured {
Text(String(localized: "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.", comment: "Best Value alert setup hint"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
if enabled && monitoredCount == 0 {
Text(String(localized: "No stations monitored yet — open the Stations tab to load prices first.", comment: "No monitored stations hint"))
.font(.caption)
.font(.footnote)
.foregroundStyle(.secondary)
}
Text(String(localized: "Background alerts depend on when your iPhone allows them; each station alerts at most once an hour.", comment: "Proximity alerts background limitation note"))
.font(.footnote)
.foregroundStyle(.secondary)
}
.fuelBoardDisabledState(isEnabled: enabled)
} header: {
Text(String(localized: "Proximity alerts", comment: "Proximity alerts section title"))
} footer: {
let modeDescription = alertMode == .bestValue
? String(format: String(localized: "When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour.", comment: "Best Value proximity alerts section footer"), FuelSavingsFormat.money(bestValueThresholdGBP), fuel.displayName.lowercased())
: String(format: String(localized: "When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.", comment: "Proximity alerts section footer"), fuel.displayName.lowercased())
Text(modeDescription)
}
if enabled, let lastAlert {
@@ -212,21 +243,38 @@ struct AlertsView: View {
Section {
Toggle(String(localized: "Cheapest station", comment: "Live Activity toggle"), isOn: $liveActivityEnabled)
Group {
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
value: liveActivityFuel.displayName,
isEnabled: liveActivityEnabled) {
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $liveActivityFuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
}
}
}
optionRow(String(localized: "Distance", comment: "Distance picker title"),
value: laDistanceValueLabel,
isEnabled: liveActivityEnabled) {
Picker(String(localized: "Distance", comment: "Distance picker title"), selection: laChoice) {
Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(LADistanceChoice.followSearch)
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
Text(distanceLabel(forMiles: miles)).tag(LADistanceChoice.fixed(miles))
}
}
}
if liveActivityFollowsSearch {
Text(String(localized: "Uses the same distance as your station search.", comment: "Live Activity distance description, follow search"))
.font(.footnote)
.foregroundStyle(.secondary)
}
Text(String(localized: "See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel.", comment: "Live Activity section description"))
.font(.footnote)
.foregroundStyle(.secondary)
}
.fuelBoardDisabledState(isEnabled: liveActivityEnabled)
} header: {
Text(String(localized: "Live Activity", comment: "Live Activity section title"))
} footer: {
Text(String(localized: "Shows the cheapest station for the fuel and distance you pick here on the Lock Screen and Dynamic Island — independent of the Stations tab (choose Follow search to mirror its distance instead). Updates as you drive; tap to open directions. Live Activities can be disabled in System Settings → Live Activities.", comment: "Live Activity section footer"))
}
}
.navigationTitle(String(localized: "Alerts", comment: "Alerts tab title"))
+1 -1
View File
@@ -83,7 +83,7 @@ struct BestValueIntroContent: View {
.pickerStyle(.segmented)
slider(
title: String(localized: "Typical fill-up", comment: "Best Value fill-up slider label"),
title: String(localized: "Fill-up amount", comment: "Best Value fill-up slider label"),
readOut: fillUpText,
value: amountBinding,
range: FuelSavingsCalculator.fillAmountSliderRange(for: settings.preferredAmount.unit),
+279 -126
View File
@@ -29,15 +29,14 @@ struct ContentView: View {
@State private var sortMode: SortMode = ContentView.qaSortModeOverride(default: FuelStore.loadSortMode())
@State private var stationLimit: Int = ContentView.qaStationRadiusOverride(default: FuelStore.loadStationLimit())
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
@State private var priceDisplayStyle: PriceDisplayStyle = ContentView.qaPriceStyleOverride(default: FuelStore.loadPriceDisplayStyle())
@State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
@State private var fuelSavingsSettings: FuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
@State private var sessionFuelSavingsAmount: FuelSavingsFillAmount = FuelStore.loadFuelSavingsSettings().preferredAmount
@State private var fuelSavingsSettings: FuelSavingsSettings = ContentView.qaSavingsSettingsOverride(default: FuelStore.loadFuelSavingsSettings())
@State private var sessionFuelSavingsAmount: FuelSavingsFillAmount = ContentView.qaSavingsSettingsOverride(default: FuelStore.loadFuelSavingsSettings()).preferredAmount
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
@State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel()
@State private var alertsMode: AlertMode = FuelStore.loadAlertsMode()
@State private var alertsBestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
@State private var favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled()
@State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel()
@@ -114,6 +113,21 @@ struct ContentView: View {
return fallback
}
/// QA hook: `-priceStyle poundsPence` renders prices in the wide pounds &
/// pence form for ONE launch. Same reason as `-sortMode`: simctl has no tap
/// command, so the wide price has to be measured against the row's column
/// widths without walking Settings → Units for every check. Never persisted.
private static func qaPriceStyleOverride(default fallback: PriceDisplayStyle) -> PriceDisplayStyle {
#if DEBUG
let args = ProcessInfo.processInfo.arguments
if let i = args.firstIndex(of: "-priceStyle"), i + 1 < args.count,
let style = PriceDisplayStyle(rawValue: args[i + 1]) {
return style
}
#endif
return fallback
}
private static func qaSortModeOverride(default fallback: SortMode) -> SortMode {
#if DEBUG
let args = ProcessInfo.processInfo.arguments
@@ -137,31 +151,42 @@ struct ContentView: View {
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
}
private var effectiveAlertsMode: AlertMode {
(alertsMode == .bestValue && !fuelSavingsSettings.isConfigured) ? .cheapest : alertsMode
}
private func ensureBestValueAlertModeIsAvailable() {
guard alertsMode == .bestValue, !fuelSavingsSettings.isConfigured else { return }
alertsMode = .cheapest
FuelStore.saveAlertsMode(.cheapest)
}
private func updateAlertMonitor(fuel overrideFuel: FuelType? = nil) {
monitor.update(
stations: stations,
favourites: refreshedFavourites,
fuel: overrideFuel ?? alertsFuel,
radiusKM: effectiveAlertsRadiusKM,
alertMode: effectiveAlertsMode,
alertMode: .cheapest,
bestValueThresholdGBP: alertsBestValueThresholdGBP,
fuelSavingsSettings: fuelSavingsSettings,
fuelSavingsAmount: sessionFuelSavingsAmount
)
}
/// QA hook: `-qaConfigureSavings` configures the Fuel Savings feature for the
/// session (the sheet's Best Value card, the Worth the Drive sort and the
/// Key sheet's Best Value row are ALL gated on `settings.isConfigured`), so
/// they can be screenshotted without walking Settings. It writes through the
/// store exactly as the Settings screen does and returns what it wrote, so
/// the app runs on the same stored values — not a stubbed, parallel one.
static func qaSavingsSettingsOverride(default value: FuelSavingsSettings) -> FuelSavingsSettings {
#if DEBUG
guard ProcessInfo.processInfo.arguments.contains("-qaConfigureSavings") else { return value }
let seeded = FuelSavingsSettings(
enabled: true,
mpg: value.mpg,
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds)
)
FuelStore.saveFuelSavingsSettings(seeded)
return seeded
#else
return value
#endif
}
private func installLocationUpdateHook() {
locationManager.onLocationUpdate = { [weak monitor] in
locationManager.onLocationUpdate = {
self.updateAlertMonitor()
// The Live Activity follows the same wake-ups — this hook
// fires on every fix incl. background significant-change
@@ -401,28 +426,27 @@ struct ContentView: View {
/// (computing a journey for each) and `savingsLookup` immediately scanned them
/// all AGAIN to fill the per-station map. Both now share one result.
///
/// The baseline is chosen with EXACTLY the loop and guards of
/// `FuelSavingsEstimator.baselineStation`, so the row labelled "Closest
/// station" cannot move — and it is still selected when the feature is
/// unconfigured (it names that row), while the maps are only built when the
/// labels will actually be shown.
/// The baseline is chosen with the same journey guards as
/// `FuelSavingsEstimator.baselineStation` when configured; with the feature
/// off, the ordinary Closest station still names that row.
private func savingsScaffold() -> (baseline: FuelStation?, journeys: [String: FuelSavingsJourney]) {
guard let location else { return (nil, [:]) }
let configured = fuelSavingsSettings.isConfigured
guard fuelSavingsSettings.isConfigured else {
let baseline = FuelSavingsEstimator.baselineStation(stations, fuel: selectedFuel) {
savingsJourneyMiles(for: $0, location: location)
}
return (baseline, [:])
}
var journeys: [String: FuelSavingsJourney] = [:]
var bestStation: FuelStation?
var bestMiles = Double.infinity
for station in stations where station.prices[selectedFuel] != nil {
guard let journey = savingsJourneyMiles(for: station, location: location) else { continue }
if configured { journeys[station.id] = journey }
let miles = journey.miles
guard miles.isFinite, miles >= 0 else { continue }
if miles < bestMiles {
bestMiles = miles
bestStation = station
if let journey = savingsJourneyMiles(for: station, location: location) {
journeys[station.id] = journey
}
}
return (bestStation, configured ? journeys : [:])
let baseline = FuelSavingsEstimator.baselineStation(stations, fuel: selectedFuel) {
journeys[$0.id]
}
return (baseline, journeys)
}
/// Row/detail savings lookup with the comparison baseline and the per-station
@@ -800,6 +824,7 @@ struct ContentView: View {
station: station,
location: location,
distanceUnit: distanceUnit,
priceStyle: priceDisplayStyle,
fuelSavings: savingsLookup(baseline: scaffold.baseline,
journeys: scaffold.journeys)(station),
fuelSavingsAmount: sessionFuelSavingsAmount,
@@ -829,6 +854,15 @@ struct ContentView: View {
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardRoadDistanceDebugUpdated)) { _ in
roadDistanceDebugStatus = RoadDistanceService.currentDebugStatus()
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardOpenBestValueSettings)) { _ in
// "Setup Best Value" in the How Best Value works sheet: close
// the More info sheet it was opened from (nil dismisses it —
// the sheet is item-driven) and switch to Settings. Tag 3 is
// the Settings tab; its Best Value section is the first item
// in the list, so the controls are on screen on arrival.
monitor.pendingDetailStation = nil
selectedTab = 3
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
let info = note.userInfo ?? [:]
if let id = info["stationID"] as? String,
@@ -1109,7 +1143,6 @@ struct ContentView: View {
// or the choice wouldn't take effect until the next launch.
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
sessionFuelSavingsAmount = fuelSavingsSettings.preferredAmount
ensureBestValueAlertModeIsAvailable()
locationManager.startForegroundTracking()
installLocationUpdateHook()
ensureBackgroundMotionTrackingIfNeeded()
@@ -1119,12 +1152,16 @@ struct ContentView: View {
}
}
.onChange(of: fuelSavingsSettings) { old, new in
// An explicit Settings default edit supersedes the temporary slider
// override. Changes to MPG/enabled alone leave the session fill intact.
if old.preferredAmount != new.preferredAmount {
sessionFuelSavingsAmount = new.preferredAmount
}
// Fuel Savings changed in Settings while the app was running: the
// effective sort follows automatically (no stored sort is touched).
#if DEBUG
print("FB-SAVINGS settings changed: enabled=\(old.enabled)->\(new.enabled) configured=\(new.isConfigured)")
#endif
ensureBestValueAlertModeIsAvailable()
updateAlertMonitor()
}
.onChange(of: sessionFuelSavingsAmount) { _, _ in
@@ -1207,16 +1244,6 @@ struct ContentView: View {
FuelStore.saveAlertsFuel(newValue)
updateAlertMonitor(fuel: newValue)
}
.onChange(of: alertsMode) { _, newValue in
if newValue == .bestValue, !fuelSavingsSettings.isConfigured {
alertsMode = .cheapest
FuelStore.saveAlertsMode(.cheapest)
updateAlertMonitor()
return
}
FuelStore.saveAlertsMode(newValue)
updateAlertMonitor()
}
.onChange(of: alertsBestValueThresholdGBP) { _, newValue in
FuelStore.saveAlertsBestValueThresholdGBP(newValue)
updateAlertMonitor()
@@ -1523,7 +1550,6 @@ struct ContentView: View {
fuelSavingsSettings: fuelSavingsSettings,
fuelSavingsAmount: $sessionFuelSavingsAmount,
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
savingsBaselineStationName: baselineStation?.name,
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
onToggleFavourite: toggleFavourite,
onRefresh: { await refresh(force: true) }
@@ -1545,7 +1571,6 @@ struct ContentView: View {
fuelSavingsSettings: fuelSavingsSettings,
fuelSavingsAmount: $sessionFuelSavingsAmount,
savingsForStation: savingsLookup(baseline: baselineStation, journeys: scaffold.journeys),
savingsBaselineStationName: baselineStation?.name,
journeyDiagnosticsForStation: { station in journeyDiagnostics(for: station, baseline: baselineStation) },
onToggleFavourite: toggleFavourite,
onReorder: reorderFavourites,
@@ -1561,9 +1586,6 @@ struct ContentView: View {
enabled: $alertsEnabled,
radius: $alertsRadius,
fuel: $alertsFuel,
alertMode: $alertsMode,
bestValueThresholdGBP: $alertsBestValueThresholdGBP,
bestValueConfigured: fuelSavingsSettings.isConfigured,
favouriteDropEnabled: $favouriteDropAlertsEnabled,
favouriteDropFuel: $favouriteDropAlertsFuel,
followsSearch: $alertsFollowsSearch,
@@ -1590,6 +1612,7 @@ struct ContentView: View {
priceDisplayStyle: $priceDisplayStyle,
stationTapAction: $stationTapAction,
fuelSavingsSettings: $fuelSavingsSettings,
bestValueThresholdGBP: $alertsBestValueThresholdGBP,
dataRefreshMode: $dataRefreshMode,
alertsFuel: alertsFuel,
alertsRadiusKM: alertsRadius,
@@ -1773,20 +1796,42 @@ struct StationRow: View {
let distanceUnit: DistanceUnit
let priceDisplayStyle: PriceDisplayStyle
let baselinePrice: Double?
/// This row is the cheapest price in range — the caller's existing
/// `topStationID` test. Feeds the CHEAPEST badge.
let isTopResult: Bool
let isFavourite: Bool
/// Label for the cheapest-pump-price badge ("TOP" in price/distance order,
/// "CHEAPEST" when the list is ranked by net saving).
var topBadgeText: String = "TOP"
var tapAction: StationTapAction = .openMap
var fuelSavings: FuelSavingsAvailability?
/// The session fill-up figure (the one the adjustment control edits). The
/// compact banner does not print it today, but a row that carried a hard
/// zero would print "for £0" the moment it did — pass the real figure.
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
/// The user's Best Value alert threshold — the app's existing gate for
/// calling a saving worth the drive, so a row labels itself BEST VALUE by
/// exactly the rule the alerts already use. Read per row (the alert path
/// reads it per evaluation too); pass explicitly to inject a value.
var bestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
var onToggleFavourite: () -> Void = {}
var onShowDetails: (FuelStation) -> Void = { _ in }
/// The price's point size, scaled with Dynamic Type: the price is built from
/// an explicit size (its "p"/"/L" suffixes are sized relative to it inside
/// `priceTextAttributed`), so it needs a metric rather than a font style.
/// 22pt keeps the price the row's loudest element after the station name.
@ScaledMetric(relativeTo: .title2) private var priceSize: CGFloat = 22
/// The identity column's share of the ROW, for every row and both price
/// styles. The mockup's divider sits at ~53% of its row; this is that ratio,
/// held per row rather than derived from the row's text — the point is that
/// the hairline, the price axis and the star cannot move as the list scrolls.
/// The price column takes the remainder, and its listing verdict wraps
/// vertically instead of widening the column and dragging the hairline.
///
/// One value for both price styles: the star used to sit at the end of the
/// price line and hold ~28pt of the price column, so the wide form `£1.58⁷/L`
/// — ~20pt wider than the bare `158.7` at the same point size — did not fit on
/// a 393pt screen. With the star now under the station logo the price column
/// has that width back, so the identity column no longer has to give width up
/// in pounds & pence, and names keep the same room in both styles.
private static let leftSectionShare: CGFloat = 0.52
private var ragColor: Color {
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
switch RAGRating.rating(price: price, cheapest: baselinePrice) {
@@ -1812,6 +1857,26 @@ struct StationRow: View {
return abs(price - baselinePrice) <= 0.05
}
/// The row's single recommendation badge, if any. BEST VALUE (worth the
/// drive by the alert threshold) outranks CHEAPEST; NEAREST never gets one,
/// being context rather than a recommendation.
private var recommendationBadge: StationRecommendationBadge? {
StationRecommendationBadge.badge(
netSavingGBP: fuelSavings?.result?.netSavingGBP,
isCheapest: isTopResult,
bestValueThresholdGBP: bestValueThresholdGBP
)
}
/// VoiceOver: the price, then the delta the redesign no longer prints — the
/// listing shows a savings capsule in its place, so the comparison against
/// the cheapest price in range would otherwise be visible only as a colour.
private func priceSpokenLabel(_ price: Double) -> String {
guard let deltaText else { return FuelStore.priceTextSpoken(price) }
let comparison = String(localized: "vs cheapest", comment: "Price delta comparison caption")
return "\(FuelStore.priceTextSpoken(price)), \(isCheapestPrice ? deltaText : "\(deltaText) \(comparison)")"
}
private func handleTap() {
switch tapAction {
case .openMap:
@@ -1828,9 +1893,31 @@ struct StationRow: View {
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 12) {
// LEFT — round brand logo (or generic fuel pump fallback)
// Two sections, one hairline: station identity on the left, price and
// savings on the right. No wrapper VStack and no full-width verdict
// banner under the row — the savings capsule carries the verdict inside
// the right-hand column, and the extra travel behind it is explained in
// the detail sheet instead of being repeated on every row.
HStack(alignment: .center, spacing: 12) {
// LEFT SECTION — station identity: brand logo, then name, address and
// distance. Given a FIXED SHARE of the row (~52%, the mockup's ratio)
// rather than sizing to its content: sized to content, this column
// grew and shrank with each row's badge and savings copy, which moved
// the hairline 23pt between adjacent rows (x767 on a BEST VALUE row,
// x699 on a "costs more" row) — so the divider and the price axis
// both jumped sideways as the list scrolled. One share for every row
// puts them on a single vertical line down the whole list.
HStack(alignment: .top, spacing: 12) {
// The logo, with the favourite star directly beneath it. Moving
// the star out of the price line is what gives the price column
// its width back: sat at the end of that line it held ~28pt of
// the column, which is exactly the space the wide pounds &
// pence price needs for its own digits on a 393pt screen.
// 10pt between logo and star: at 4pt the star's glyph sat almost
// against the logo's bottom edge and read as part of it rather
// than as the control it is.
VStack(spacing: 10) {
// Round brand logo (or generic fuel pump fallback)
Group {
if let asset = station.brandImageName {
Image(asset)
@@ -1849,13 +1936,25 @@ struct StationRow: View {
.overlay(Circle().stroke(Color.primary.opacity(0.10), lineWidth: 1))
.shadow(color: .black.opacity(0.08), radius: 2, y: 1)
// MIDDLE — name, full address, distance
favouriteButton
}
// Name, full address, distance
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
// The name wraps, capped at THREE lines. The row is tall
// enough for a two-line name, but an unbounded wrap let one
// very long forecourt name set the height of the whole row —
// the excessive row heights this refinement removes. Past
// three lines the name ellipsises; the full name is always
// in Station Details. Baseline alignment keeps the notice
// symbols on the name's FIRST line instead of floating
// beside a two-line name.
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(station.name)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.lineLimit(3)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
ForEach(StationNotice.notices(for: station)) { notice in
Image(systemName: notice.symbolName)
.font(.caption2.weight(.semibold))
@@ -1863,97 +1962,151 @@ struct StationRow: View {
.accessibilityLabel(notice.title)
}
}
// Wraps for the same reason as the name — addresses here run
// to 70 characters at the 95th percentile.
Text(StationAddressLine.display(address: station.address,
postcode: station.postcode,
name: station.name))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
if let location {
let distance = FuelStore.stationDistanceDisplay(
station: station, userLat: location.lat, userLng: location.lng
)
Text(distance.labelWithReturn(unit: distanceUnit))
// ONE-WAY distance: the listing shows how far the station
// is. The return leg stays in the savings explanation, where
// the extra travel it drives is actually costed.
HStack(spacing: 3) {
Image(systemName: "location.fill")
.font(.caption2)
.accessibilityHidden(true)
Text(distance.label(unit: distanceUnit))
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
.accessibilityLabel(distance.spokenLabelWithReturn(unit: distanceUnit))
}
.foregroundStyle(.secondary)
.accessibilityElement(children: .ignore)
.accessibilityLabel(distance.spokenLabel(unit: distanceUnit))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
// RIGHT — price in £ (no p) + difference vs cheapest
VStack(alignment: .trailing, spacing: 2) {
if isTopResult {
// The badge always marks the CHEAPEST PUMP PRICE (never
// "best saving"), so in Best Value order — where the
// top row is the best net saving — it may sit lower down
// the list, and says so. It sits ABOVE the price it
// annotates rather than beside the name, so it reads as
// part of the price and the name keeps its width.
Text(topBadgeText)
.font(.caption2.bold())
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(Capsule().fill(.blue.opacity(0.15)))
.foregroundStyle(.blue)
.lineLimit(1)
.fixedSize()
}
.frame(maxWidth: .infinity, alignment: .leading)
.containerRelativeFrame(.horizontal, alignment: .leading) { length, _ in
length * Self.leftSectionShare
}
// 1pt hairline between the two sections. Inset top and bottom so it
// spans the row's content without touching the row edges, and
// decorative: the columns are told apart by their content, and two
// hairs per row would just be noise to VoiceOver.
//
// `secondary`, not `separator`: over a dark row fill the system
// separator measures 7/255 brighter than the fill — measurably
// invisible — while the mockup's rule sits ~112 above it. Secondary
// lands on the mockup's own value in both appearances.
Rectangle()
.fill(Color.secondary)
.frame(width: 1)
.frame(maxHeight: .infinity)
.padding(.vertical, 6)
.accessibilityHidden(true)
// RIGHT SECTION — price and savings, every element leading-aligned on
// ONE axis. The 12pt row spacing plus this column's leading padding
// puts that axis ~22pt right of the hairline. (The mockup centres the
// price against the badge so its status dot hangs into the gutter; the
// redesign was explicitly asked not to do that.)
VStack(alignment: .leading, spacing: 12) {
if let badge = recommendationBadge {
badgeCapsule(badge)
}
// The price and its verdict capsule are ONE vertically grouped
// element: 5pt binds them as "this price, this outcome", while
// the badge keeps the column's full 12pt above so the three
// lines do not read as equals.
VStack(alignment: .leading, spacing: 5) {
// Price line: the status dot and the price, leading-aligned
// on the column's one axis. The star no longer ends this
// line — it sits under the station logo — so this whole
// column width now belongs to the price, which is what lets
// the wide pounds & pence form (£1.58⁷/L) render at full
// size rather than being scaled down or truncated.
if let price = station.prices[fuel] {
HStack(spacing: 5) {
Circle()
.fill(ragColor)
.frame(width: 8, height: 8)
FuelStore.priceTextAttributed(price, style: priceDisplayStyle, size: 20, weight: .bold)
}
if let deltaText {
Text(deltaText)
.font(.caption2.bold().monospaced())
.foregroundStyle(ragColor)
if !isCheapestPrice {
// "+8.0p" on its own never said what it measured
// against; the delta is against the cheapest price
// in range in EVERY sort mode.
Text("vs cheapest")
.font(.caption2)
.foregroundStyle(.secondary)
PriceRatingDot(color: ragColor)
FuelStore.priceTextAttributed(price, style: priceDisplayStyle,
size: priceSize, weight: .bold)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
// The visible "+1.8p vs cheapest" line is gone with the
// redesign, so the comparison rides on the price's label.
.accessibilityElement(children: .ignore)
.accessibilityLabel(priceSpokenLabel(price))
}
if let result = fuelSavings?.result {
savingsCapsule(result)
}
}
}
// FAR RIGHT — favourite star
Button {
onToggleFavourite()
} label: {
Image(systemName: isFavourite ? "star.fill" : "star")
.foregroundStyle(isFavourite ? .yellow : .secondary)
}
.buttonStyle(.borderless)
}
if fuelSavings != nil {
// Full-width verdict banner under the row. Inline in the middle
// column it truncated ("Approx. £2.75 better off after tra…")
// because the
// price block squeezes that column — and the verdict is the
// point of this sort mode, so it gets its own line.
FuelSavingsSummaryView(
availability: fuelSavings,
amount: fuelSavingsAmount,
baselineStationName: nil,
compact: true,
distanceUnit: distanceUnit
)
}
.frame(maxWidth: .infinity, alignment: .leading)
// The spec's ~20-24pt of air between the divider and the price
// column: the row's own 12pt spacing plus this 10pt.
.padding(.leading, 10)
}
.contentShape(Rectangle())
.onTapGesture(perform: handleTap)
.onLongPressGesture(perform: showDetails)
}
/// The favourite toggle, sitting under the station logo. Kept as its own view
/// so it stays a separate VoiceOver element — the price beside it is a single
/// grouped label — and keeps its own tap target inside the row's tap and
/// long-press gestures.
private var favouriteButton: some View {
Button {
onToggleFavourite()
} label: {
FavouriteStarGlyph(isFavourite: isFavourite)
}
.buttonStyle(.borderless)
}
/// The listing's verdict capsule — ONE component for every status, so the
/// baseline's "Closest station" pill carries the same soft blue fill, white
/// text and shape rules as Saves, Costs and Break even. The detail sheet
/// keeps `FuelSavingsCapsule` to itself, unchanged.
private func savingsCapsule(_ result: FuelSavingsResult) -> some View {
ListingFuelSavingsCapsule(result: result)
}
/// One recommendation badge, above the price it annotates. Badges stay
/// TINTED with coloured text — unlike the verdict capsules, which are filled
/// with white text: a badge annotates the price rather than reporting an
/// outcome, so it should stay quieter than the pill beneath it. Tinted text
/// also keeps its contrast in both appearances at every Dynamic Type size
/// without introducing a colour outside the palette.
private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View {
StationLabelBadge(label: badge.title, color: badge.tint)
}
}
/// Badge copy and colour for `StationRecommendationBadge` — view concerns, so
/// they stay out of the shared model the tests cover.
private extension StationRecommendationBadge {
var title: String {
switch self {
case .bestValue:
return String(localized: "BEST VALUE", comment: "Station badge: saving clears the Best Value threshold")
case .cheapest:
return String(localized: "CHEAPEST", comment: "Station badge: cheapest price in range")
}
}
var tint: Color { self == .bestValue ? .green : .blue }
}
// MARK: - Location manager
+60 -15
View File
@@ -21,10 +21,9 @@ struct FavouritesView: View {
/// still never touches the Settings default.
let fuelSavingsSettings: FuelSavingsSettings
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
/// Estimated saving for a favourite, against the nearest eligible station —
/// the same baseline and definition the Stations list uses.
/// Estimated saving for a favourite, against the closest station by
/// journey distance — the same baseline and definition the Stations list uses.
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
var savingsBaselineStationName: String?
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
/// Persists a reordered favourites array (after drag-and-drop).
@@ -49,6 +48,14 @@ struct FavouritesView: View {
/// Trends sheet (price history chart) presentation state.
@State private var showTrends = false
/// The Key (legend) sheet — the same sheet the Stations tab presents, opened
/// from the info button beside Edit.
@State private var showKey = false
/// The Key sheet's two nested sheets, owned here because its rows set them
/// (same arrangement as StationsView, so the QA hooks behave identically).
@State private var showBestValueInfo = false
@State private var showImportantInformation = false
/// Station picked for the More-info sheet (tap action = showDetails).
@State private var detailStation: FuelStation?
@@ -70,6 +77,15 @@ struct FavouritesView: View {
}
private var cheapestPrice: Double? { cheapest?.prices[activeFuel] }
/// The baseline note is useful only when at least one visible favourite has
/// an actual savings comparison. An unavailable result (for example, while
/// location is missing) must not leave an orphaned explanation below the list.
private var showsSavingsComparisonExplanation: Bool {
fuelSavingsSettings.isConfigured && ordered.contains { station in
savingsForStation(station)?.result != nil
}
}
/// IDs favourited for the active fuel — the star state on each row.
private var activeFuelFavouriteIDs: Set<String> {
Set(favourites.filter { $0.fuel == activeFuel }.map(\.station.id))
@@ -84,7 +100,6 @@ struct FavouritesView: View {
fuelSavingsSettings: FuelSavingsSettings = .defaults,
fuelSavingsAmount: Binding<FuelSavingsFillAmount>,
savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil },
savingsBaselineStationName: String? = nil,
journeyDiagnosticsForStation: @escaping (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil },
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
@@ -99,7 +114,6 @@ struct FavouritesView: View {
self.fuelSavingsSettings = fuelSavingsSettings
_fuelSavingsAmount = fuelSavingsAmount
self.savingsForStation = savingsForStation
self.savingsBaselineStationName = savingsBaselineStationName
self.journeyDiagnosticsForStation = journeyDiagnosticsForStation
self.onToggleFavourite = onToggleFavourite
self.onReorder = onReorder
@@ -147,12 +161,16 @@ struct FavouritesView: View {
if fuelSavingsSettings.isConfigured {
Section {
FuelSavingsAdjustmentView(
amount: $fuelSavingsAmount,
referencePricePencePerLitre: cheapestPrice
)
} footer: {
Text(String(localized: "Savings below are estimated against the nearest station with a price for this fuel — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation"))
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
}
}
if showsSavingsComparisonExplanation {
Section {
Text(String(localized: "Price colours show pump price competitiveness. Savings compare with your closest station, including journey costs.", comment: "Station listing price and savings explanation"))
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
@@ -167,10 +185,8 @@ struct FavouritesView: View {
baselinePrice: cheapestPrice,
isTopResult: station.id == cheapest?.id,
isFavourite: activeFuelFavouriteIDs.contains(station.id),
topBadgeText: "CHEAPEST",
tapAction: stationTapAction,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
onShowDetails: { detailStation = $0 }
)
@@ -179,8 +195,13 @@ struct FavouritesView: View {
} header: {
Text(String(format: String(localized: "%@ favourites", comment: "Favourites section title for fuel type"), activeFuel.displayName))
} footer: {
VStack(alignment: .leading, spacing: 12) {
Text(String(localized: "The first station is the favourite used in single widgets. Tap Edit, then drag to reorder.", comment: "Favourites reorder explanation"))
}
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
if let cheapest = cheapest {
Section {
@@ -193,8 +214,8 @@ struct FavouritesView: View {
}
.navigationTitle(String(localized: "Favourites", comment: "Favourites tab title"))
.toolbar {
if !favourites.isEmpty {
ToolbarItemGroup(placement: .topBarTrailing) {
if !favourites.isEmpty {
Button {
showTrends = true
} label: {
@@ -203,7 +224,24 @@ struct FavouritesView: View {
}
EditButton()
}
// Same button and same slot as the Stations tab: last in the
// group, so it sits to the right of Edit. Shown even with no
// favourites — the legend explains the prices and labels, which
// matter before anything is starred.
Button {
showKey = true
} label: {
Image(systemName: "info.circle")
}
.accessibilityLabel(String(localized: "Key", comment: "Key button accessibility label"))
}
}
.sheet(isPresented: $showKey) {
KeySheetView(
fuelTint: activeFuel.tintColor,
showBestValueInfo: $showBestValueInfo,
showImportantInformation: $showImportantInformation
)
}
.sheet(isPresented: $showTrends) {
TrendsView(
@@ -219,9 +257,10 @@ struct FavouritesView: View {
station: station,
location: location,
distanceUnit: distanceUnit,
priceStyle: priceDisplayStyle,
selectedFuel: activeFuel,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
savingsBaselineStationName: savingsBaselineStationName,
journeyDiagnostics: journeyDiagnosticsForStation(station),
// Every station listed here IS a favourite for the tab's
// fuel, so the star starts filled; tapping it unstars.
@@ -229,6 +268,12 @@ struct FavouritesView: View {
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
)
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardOpenBestValueSettings)) { _ in
// "Setup Best Value" from the explanation sheet, opened inside
// this view's More info sheet. That sheet closes itself; this one
// is presented from HERE and would otherwise stay over Settings.
detailStation = nil
}
.onAppear {
#if DEBUG
// QA hook: launch with `-showTrends` to open the sheet
+12
View File
@@ -0,0 +1,12 @@
import SwiftUI
/// Applies FuelBoard's shared unavailable appearance and native interaction state
/// to a complete dependent row or group. Apply it once at the highest dependent
/// container so labels, values, icons, descriptions and controls fade together.
extension View {
func fuelBoardDisabledState(isEnabled: Bool) -> some View {
disabled(!isEnabled)
.allowsHitTesting(isEnabled)
.opacity(isEnabled ? 1 : 0.5)
}
}
+209 -173
View File
@@ -2,7 +2,8 @@ import SwiftUI
struct FuelSavingsAdjustmentView: View {
@Binding var amount: FuelSavingsFillAmount
let referencePricePencePerLitre: Double?
@State private var showingComparisonHelp = false
private var range: ClosedRange<Double> {
FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit)
@@ -25,8 +26,33 @@ struct FuelSavingsAdjustmentView: View {
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("How much are you filling up?")
HStack {
Text("How much fuel?")
.font(.subheadline.weight(.semibold))
Spacer()
Button {
showingComparisonHelp = true
} label: {
Image(systemName: "info.circle")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.accessibilityLabel("About comparison modes")
.popover(isPresented: $showingComparisonHelp) {
VStack(alignment: .leading, spacing: 12) {
Text("Comparison modes")
.font(.headline)
Text("£: Compare how much fuel your budget buys at each station.")
.fixedSize(horizontal: false, vertical: true)
Text("Litres: Compare the cost of buying the same amount of fuel.")
.fixedSize(horizontal: false, vertical: true)
}
.font(.footnote)
.frame(width: 280, alignment: .leading)
.padding()
.presentationCompactAdaptation(.popover)
}
}
Picker("Fill-up unit", selection: unitBinding) {
ForEach(FuelSavingsFillUnit.allCases) { unit in
Text(unit.displayName).tag(unit)
@@ -41,7 +67,7 @@ struct FuelSavingsAdjustmentView: View {
.monospacedDigit()
.frame(width: 72, alignment: .trailing)
}
Text("Changes the estimate for this session only — your Settings default stays unchanged.")
Text("Adjust your fill-up amount for this visit. Your default won’t change.")
.font(.caption2)
.foregroundStyle(.secondary)
}
@@ -52,12 +78,9 @@ struct FuelSavingsAdjustmentView: View {
get: { amount.unit },
set: { newUnit in
guard newUnit != amount.unit else { return }
if let referencePricePencePerLitre,
let converted = try? FuelSavingsCalculator.convert(amount: amount, to: newUnit, referencePricePencePerLitre: referencePricePencePerLitre) {
amount = converted
} else {
amount = FuelSavingsFillAmount(value: newUnit == .pounds ? 40 : 30, unit: newUnit)
}
// No one litre figure equals a budget at every candidate price,
// so switching modes starts from that mode's configured default.
amount = FuelSavingsCalculator.newComparisonAmount(afterSwitching: amount, to: newUnit)
}
)
}
@@ -70,182 +93,42 @@ struct FuelSavingsAdjustmentView: View {
}
}
struct FuelSavingsSummaryView: View {
let availability: FuelSavingsAvailability?
let amount: FuelSavingsFillAmount
let baselineStationName: String?
var compact: Bool = false
/// Sizes the "extra return travel" caption in the user's own unit (the
/// stored figure is miles) — used by both the row banner and the detail sheet.
var distanceUnit: DistanceUnit = .miles
var showInfoAction: (() -> Void)? = nil
var body: some View {
switch availability {
case .available(let result):
if compact {
// FULL-WIDTH banner under the row. Inline inside the middle
// column the capsule truncated ("Approx. £2.75 better off after
// tra…") because
// the column is squeezed by the price block — the verdict is the
// point of this sort mode, so it gets its own line with room.
// Colour carries the verdict: green save, red worse off, grey
// break even, blue baseline. lineLimit + minimumScaleFactor keep
// it to one line, so rows stay even.
VStack(alignment: .leading, spacing: 2) {
capsule(for: result, compact: true)
.accessibilityLabel(accessibilityHeadline(for: result))
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit,
confidence: result.journeyConfidence
) {
Text(extraTravel)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.85)
.accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel))
}
}
// Aligns the banner with the row's text column (46 pt logo + 12
// pt spacing) so it reads as part of the row, not the cell.
.padding(.leading, 58)
} else {
// Detail sheet: the SAME verdict capsule and the SAME caption as
// the listing rows (one implementation, so wording and colour
// cannot drift), followed by the breakdown only this view has
// room for. The old shape led with a status word + headline that
// matched neither the listing nor each other.
VStack(alignment: .leading, spacing: 10) {
capsule(for: result, compact: false)
.accessibilityLabel(accessibilityHeadline(for: result))
LabeledContent("Your fill-up", value: amountText)
LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres))
.monospacedDigit()
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit,
confidence: result.journeyConfidence
) {
Text(extraTravel)
.font(.subheadline)
.foregroundStyle(.secondary)
.accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel))
}
if result.status == .baseline {
Text("Your closest station with a price for this fuel — every other saving is measured against it.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("Compared with \(baselineStationName ?? "your nearest station").")
.font(.caption)
.foregroundStyle(.secondary)
LabeledContent(
result.grossSavingGBP >= 0 ? "Fuel-price saving" : "Fuel-price difference",
value: result.grossSavingGBP >= 0
? FuelSavingsFormat.money(result.grossSavingGBP)
: "Costs \(FuelSavingsFormat.money(abs(result.grossSavingGBP))) more"
)
LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP))
Divider()
LabeledContent {
Text(result.headline())
.fontWeight(.semibold)
.foregroundStyle(capsuleColor(for: result.status))
.monospacedDigit()
} label: {
Text("Total")
.fontWeight(.semibold)
}
if result.journeyConfidence.isEstimated {
Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.")
.font(.caption2)
.foregroundStyle(.secondary)
}
Text("Estimate covers fuel cost only. It excludes time, parking, tolls and wear.")
.font(.caption2)
.foregroundStyle(.secondary)
}
if let showInfoAction {
Rectangle()
.fill(Color(.separator))
.frame(height: 0.5)
.padding(.vertical, 4)
Button(action: showInfoAction) {
HStack(spacing: 8) {
Text("How Best Value works")
Spacer(minLength: 8)
Image(systemName: "chevron.forward")
.font(.footnote.weight(.semibold))
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
}
}
}
case .unavailable(let reason):
Text(compact ? "Savings unavailable" : "Best Value unavailable: \(reason)")
.font(compact ? .caption2 : .caption)
.foregroundStyle(.secondary)
case nil:
EmptyView()
}
}
private var amountText: String {
switch amount.unit {
case .pounds: return String(format: "£%.0f", amount.value)
case .litres: return String(format: "%.0f litres", amount.value)
}
}
/// VoiceOver form of the extra-travel caption ("Approximately 6.4 miles extra
/// return travel"): the abbreviation is spelled out and the unit is a word.
/// Falls back to the visible string if no spoken form exists.
private func spokenExtraTravel(for result: FuelSavingsResult, fallback: String) -> String {
FuelSavingsFormat.extraTravelSpokenLabel(
extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit,
confidence: result.journeyConfidence
) ?? fallback
}
/// VoiceOver form of the verdict. `spokenHeadline()` spells "Approx." out for
/// estimated comparisons and leaves the plain words ("Break even", "Closest
/// station") alone — one source, so visible and spoken copy cannot drift.
private func accessibilityHeadline(for result: FuelSavingsResult) -> String {
result.spokenHeadline()
}
/// The colour-coded verdict capsule — ONE implementation for the listing rows
/// and the detail sheet, so the verdict reads identically in both; the sheet
/// just gets the larger font and padding.
/// The colour-coded monetary verdict capsule used by the detail sheet and other
/// full financial summaries. Listing rows use `ListingFuelSavingsCapsule`.
///
/// An HStack with an explicit spacing, NOT a Label: given the width to spare,
/// a Label lays its icon and title out across the row and leaves a visible
/// gap between them (22pt in the listing row), which read as the text having
/// drifted away from its symbol. The icon is decorative here — the verdict
/// is in the text — so VoiceOver reads the capsule as one phrase.
private func capsule(for result: FuelSavingsResult, compact: Bool) -> some View {
/// a Label lays its icon and title out across the row and leaves a visible gap
/// between them (22pt in the listing row), which read as the text having
/// drifted away from its symbol. The icon is decorative here — the verdict is
/// in the text — so VoiceOver reads the capsule as one phrase.
struct FuelSavingsCapsule: View {
let result: FuelSavingsResult
var compact: Bool = true
var body: some View {
HStack(spacing: compact ? 3 : 5) {
Image(systemName: symbol(for: result.status))
Image(systemName: Self.symbol(for: result.status))
.accessibilityHidden(true)
Text(result.capsuleHeadline())
Text(compact ? result.capsuleHeadline() : result.detailCapsuleHeadline())
.lineLimit(1)
.minimumScaleFactor(0.8)
// Measured on a 393pt screen: the capsule is handed ~101pt, of
// which the icon, its spacing and the 8pt padding either side
// take 32pt, leaving ~69pt for the text. "Closest station" needs
// ~90pt at full size, so a 0.8 floor — 72pt — could not reach it
// and the row showed "Closest stati…" instead. 0.7 leaves ~63pt,
// which clears it at ~10pt type: scaled, never truncated.
.minimumScaleFactor(0.7)
}
.font(compact ? .caption.weight(.semibold) : .headline)
.foregroundStyle(capsuleColor(for: result.status))
.foregroundStyle(Self.color(for: result.status))
.padding(.horizontal, compact ? 8 : 12)
.padding(.vertical, compact ? 4 : 7)
.background(Capsule().fill(capsuleColor(for: result.status).opacity(0.15)))
.background(Capsule().fill(Self.color(for: result.status).opacity(0.15)))
}
/// Verdict colour: green save, red worse off, grey break even, blue
/// baseline — the same green/amber/red family the price dot already uses.
private func capsuleColor(for status: FuelSavingsStatus) -> Color {
static func color(for status: FuelSavingsStatus) -> Color {
switch status {
case .worthTheDrive: return .green
case .notWorthTheDrive: return .red
@@ -254,7 +137,7 @@ struct FuelSavingsSummaryView: View {
}
}
private func symbol(for status: FuelSavingsStatus) -> String {
static func symbol(for status: FuelSavingsStatus) -> String {
switch status {
case .worthTheDrive: return "checkmark.circle.fill"
case .breakEven: return "equal.circle.fill"
@@ -263,3 +146,156 @@ struct FuelSavingsSummaryView: View {
}
}
}
/// The station listing's verdict capsule: the words on the first line, the figure
/// on its own line beneath, and "(approx.)" on a third when the journey is an
/// estimate.
///
/// Stations and Favourites share `StationRow`, so both render this. The detail
/// sheet keeps `FuelSavingsCapsule`'s colour-coded monetary verdict on one line;
/// the Key sheet's legend renders this with `showsAmount: false` so it never
/// displays an invented figure.
struct ListingFuelSavingsCapsule: View {
let result: FuelSavingsResult
/// Station rows carry the figure; the Key sheet's legend shows the wording only.
var showsAmount: Bool = true
/// The height the text actually drew, so the shape can follow the drawn line
/// count rather than the data's. See `drawnLineCount`.
@State private var textHeight: CGFloat = 0
/// One caption line's height, scaled with Dynamic Type, to divide that by.
@ScaledMetric(relativeTo: .caption) private var captionLineHeight: CGFloat = 14
private var verdict: FuelSavingsResult.ListingVerdict {
result.listingVerdict(showsAmount: showsAmount)
}
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 3) {
Image(systemName: symbol)
.accessibilityHidden(true)
// One Text per line, not a "\n" inside one string: each line keeps its
// own localisation key and the amount can be monospaced on its own.
// Left-aligned: the word and its figure share a left edge, so the
// block reads as a column rather than a ragged centre.
VStack(alignment: .leading, spacing: 0) {
Text(verdict.headline)
if let amount = verdict.amount {
Text(amount)
.monospacedDigit()
}
if let qualifier = verdict.qualifier {
Text(qualifier)
}
}
.multilineTextAlignment(.leading)
// Never compress or truncate: the capsule wraps and the row grows taller.
.fixedSize(horizontal: false, vertical: true)
.background(
GeometryReader { proxy in
Color.clear
.preference(key: CapsuleTextHeightKey.self,
value: proxy.size.height)
}
)
}
// White on the muted fill. These capsules carry a figure the eye is asked
// to read at a glance, which is why they are the only filled-and-white
// surfaces in the app; every badge stays tinted with coloured text.
.font(.caption.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 4)
// Saves and Costs share one minimum width, so their trailing edges line
// up down the list whatever the figure's length. Break even and Closest
// station carry no figure and keep their natural width.
.frame(minWidth: minimumWidth, alignment: .leading)
.background(shape.fill(fill))
.accessibilityElement(children: .combine)
.accessibilityLabel(verdict.spokenText)
.onPreferenceChange(CapsuleTextHeightKey.self) { textHeight = $0 }
}
/// Saves and Costs only — the two verdicts that carry a figure. See the
/// frame above: 84pt is the width the widest common amount needs
/// ("£123.45 more"), so shorter figures pad rather than shrink the pill.
private var minimumWidth: CGFloat {
switch result.status {
case .worthTheDrive, .notWorthTheDrive: return 84
case .breakEven, .baseline: return 0
}
}
private var fill: Color {
ListingCapsuleFill.color(for: result.status)
}
/// How many lines the text DREW, not how many the verdict declares.
/// "Closest station" is one declared line that wraps to two in the row's
/// narrow column, and the shape has to follow what is on screen or the pill's
/// semicircular ends crowd the text they are wrapping. Falls back to the
/// declared count on the first layout pass, before there is a height.
private var drawnLineCount: Int {
guard textHeight > 0, captionLineHeight > 0 else { return verdict.lines.count }
return max(1, Int((textHeight / captionLineHeight).rounded()))
}
/// One line keeps the capsule. Two or more switch to a rounded rect: a pill's
/// semicircular ends crowd the text corners at that height, while an 8pt
/// radius does not. Keyed to the DRAWN line count, so a wrapped "Closest
/// station" gets the same rounded rect as a figure pushed onto its own line.
private var shape: AnyShape {
drawnLineCount > 1
? AnyShape(RoundedRectangle(cornerRadius: 8))
: AnyShape(Capsule())
}
private var symbol: String {
switch result.status {
case .worthTheDrive: return "checkmark.circle"
case .notWorthTheDrive: return "arrow.up.circle"
case .breakEven: return "equal.circle"
case .baseline: return "location.circle"
}
}
}
/// Carries the drawn height of a capsule's text block up to `drawnLineCount`.
private struct CapsuleTextHeightKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
/// The station listing's verdict-capsule fills: muted semantic colours carrying
/// white text and icon.
///
/// Muted AND fixed, deliberately not the translucent tints used elsewhere in the
/// app: white text on a 15% tint over a white row measures ~1.8:1 and is
/// unreadable. Each tone below sits at a relative luminance of ~0.18 — the band
/// where white text clears 4.5:1 AND the pill still separates ~3:1 from BOTH a
/// white row and the dark card fill — so one value each works in both
/// appearances without a dynamic colour.
///
/// The saturated palette colours cannot carry white text at all: measured
/// white-on-fill is 2.2:1 for `systemGreen`, 3.6:1 for `systemRed` and 4.0:1 for
/// `systemBlue`, every one of them below the 4.5:1 a caption needs. Muting is
/// therefore a contrast requirement here, not only the mockup's taste.
enum ListingCapsuleFill {
// Trailing comments record, per colour: white-on-fill / fill-vs-white-row /
// fill-vs-dark-row, measured rather than estimated.
static let saves = Color(red: 0x45 / 255, green: 0x82 / 255, blue: 0x5E / 255) // 4.56 / 4.56 / 3.06
static let costs = Color(red: 0xA9 / 255, green: 0x63 / 255, blue: 0x5B / 255) // 4.54 / 4.54 / 3.07
static let breakEven = Color(red: 0x70 / 255, green: 0x70 / 255, blue: 0x70 / 255) // 4.95 / 4.95 / 2.81
static let closest = Color(red: 0x58 / 255, green: 0x78 / 255, blue: 0x9D / 255) // 4.52 / 4.52 / 3.09
static func color(for status: FuelSavingsStatus) -> Color {
switch status {
case .worthTheDrive: return saves
case .notWorthTheDrive: return costs
case .breakEven: return breakEven
case .baseline: return closest
}
}
}
+87 -3
View File
@@ -14,8 +14,54 @@ struct HowBestValueSheet: View {
List {
Section {
VStack(alignment: .leading, spacing: 10) {
Text("Best Value compares each station with the closest one. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.")
Text("Best Value compares each station with the closest station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.")
Text("Your fill-up amount and MPG are used to show whether you could save overall.")
// The whole point of the sheet is to explain the feature,
// so the way to switch it on lives here rather than only
// in Settings. Full-width: this is the card's one action.
Button {
openBestValueSettings()
} label: {
Text("Setup Best Value")
.font(.subheadline.weight(.semibold))
.frame(maxWidth: .infinity)
}
// `.large` is the standard iOS button height (~50pt): the
// default `.borderedProminent` is considerably shorter and
// read as a compact control rather than a button.
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.top, 2)
}
.font(.subheadline)
.padding(.vertical, 4)
}
Section("The working, step by step") {
VStack(alignment: .leading, spacing: 12) {
step("1. The baseline",
"The closest station by journey distance. It is the trip you would make anyway, so everything else is measured against it.")
step("2. The return trip",
"For each station we work out the journey there and back. Where the roads have been measured we use the real driving route; until then we double the straight-line distance and mark the figure “Approx.”.")
step("3. Extra return travel",
"This station's return trip minus the baseline's. It is the extra you add on top of the journey you were already making — not the whole trip. Because both figures are return trips, it can read larger than the one-way distance shown above the map.")
step("4. Extra driving cost",
"What that extra distance costs in fuel: the extra miles, divided by your MPG, priced at the station you are driving to.")
step("5. Fuel-price saving",
"What the difference in price per litre is worth on your fill-up.")
step("6. Total",
"The fuel-price saving minus the extra driving cost. If the drive costs more than the price saves, the total is shown as an extra cost instead, and the station is not Best Value.")
step("7. Best Value",
"The total clears the minimum saving you set in Settings → Fuel Savings.")
}
.font(.subheadline)
.padding(.vertical, 4)
}
Section {
VStack(alignment: .leading, spacing: 10) {
Text("With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest station, then deduct extra journey fuel. You do not receive cash back.")
Text("With Litres, every station is compared for the fixed quantity you selected.")
Text("Results are marked “Approx.” until road distances are available.")
.foregroundStyle(.secondary)
}
@@ -31,8 +77,46 @@ struct HowBestValueSheet: View {
}
}
}
.presentationDetents([.fraction(0.42)])
.presentationDetents([.fraction(0.72)])
.presentationBackground(Color(UIColor.systemGroupedBackground))
#if DEBUG
// QA hook: `-qaSetupBestValue` runs the Setup Best Value action without a
// tap (simctl cannot tap), so the tab switch can be screenshotted.
.task {
guard ProcessInfo.processInfo.arguments.contains("-qaSetupBestValue") else { return }
try? await Task.sleep(nanoseconds: 2_000_000_000)
openBestValueSettings()
}
#endif
}
/// Closes this sheet and asks the app to land on Settings → Best Value, whose
/// section is the first thing in its list and holds the fill-up amount, MPG
/// and minimum saving. This sheet sits two levels below the tab view — More
/// info sheet, or the Key sheet — so it cannot switch tabs itself: it closes
/// itself and posts, and ContentView does the rest.
private func openBestValueSettings() {
dismiss()
// Announce only once this sheet has animated away. The host then closes
// the sheet this one was presented from — the More info sheet, or the Key
// sheet — and switches to Settings. Asking for that while the child was
// still dismissing could drop one of the two dismissals, which is what
// left the Settings tab covered by a sheet.
Task { @MainActor in
try? await Task.sleep(nanoseconds: 500_000_000)
NotificationCenter.default.post(name: .fuelBoardOpenBestValueSettings, object: nil)
}
}
/// One numbered step: a lead-in, then the plain explanation. Same words
/// either way — this only sets the hierarchy.
private func step(_ title: String, _ explanation: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(explanation)
.foregroundStyle(.secondary)
}
}
}
@@ -46,7 +130,7 @@ struct HowBestValueRow: View {
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
Text("How Best Value works")
Text(KeySheetContent.helpTitle)
Spacer(minLength: 8)
Image(systemName: "chevron.forward")
.font(.footnote.weight(.semibold))
+193
View File
@@ -0,0 +1,193 @@
import SwiftUI
/// "Important information" — FuelBoard's prices, availability, savings, journey
/// and liability disclaimer as ONE reusable sheet.
///
/// The disclaimer is shown from more than one place, so the copy and its layout
/// live here once: a host adds an `ImportantInformationRow` (or the
/// `.importantInformationSheet(isPresented:)` modifier) and the wording can
/// never drift between entry points.
///
/// One `Section` per topic, so every heading gets its own card: an accent
/// symbol beside a semibold title, with the secondary-coloured description
/// beneath. First host: the Stations tab's Key sheet (a row at the bottom). Copy
/// lives in `Localizable.strings`, keyed by the English source string, so the web
/// copy editor covers it.
struct ImportantInformationSheet: View {
@Environment(\.dismiss) private var dismiss
/// Width of the symbol column that every heading's icon sits in.
///
/// Symbols have wildly different ink widths — `car.side` is ~34 pt wide at
/// this size while `info.circle` is ~20 pt — so without a shared column each
/// heading's TEXT would start at a different x (measured: 180 px vs 233 px
/// at 3x), which is exactly the inconsistent alignment the brief rules out.
/// `@ScaledMetric` keeps the column in step with the icon font, so it still
/// can't overrun at large Dynamic Type sizes.
@ScaledMetric(relativeTo: .title3) private var iconSlot: CGFloat = 34
var body: some View {
NavigationStack {
List {
Section {
paragraphs(
"FuelBoard uses fuel price information published through the UK Government's fuel price data scheme. Prices are supplied by retailers and may change or differ from those displayed in the app.",
"While we aim to provide accurate, up-to-date information, we cannot guarantee that displayed prices reflect those currently available at the pump."
)
} header: {
heading("Fuel Prices", systemImage: "sterlingsign.circle")
}
Section {
paragraphs(
"A displayed fuel price does not guarantee availability. Stations may temporarily run out of fuel, have pumps out of service, be closed or no longer offer a particular fuel type.",
"We recommend checking availability before making a special journey."
)
} header: {
heading("Fuel Availability", systemImage: "fuelpump.fill")
}
Section {
paragraphs(
"Savings and Best Value recommendations are estimates based on available fuel prices, your selected fill-up amount, estimated travel costs and other relevant settings.",
"Actual savings may vary depending on fuel consumption, driving conditions, routes and current pump prices. A Best Value recommendation does not guarantee the lowest overall cost."
)
} header: {
heading("Savings & Best Value", systemImage: "arrow.down.circle")
}
Section {
paragraphs(
"Travel distances, journey times and associated fuel costs are estimates. Where actual road distances are unavailable, approximate distances may be used.",
"Actual journeys may differ due to traffic, road conditions, diversions and individual driving habits."
)
} header: {
heading("Journey Estimates", systemImage: "car.side")
}
Section {
paragraphs(
"Fuel prices and availability can change without notice. Always check the current price and fuel availability before making a special journey or filling up."
)
} header: {
heading("Before You Travel", systemImage: "checkmark.circle")
}
Section {
paragraphs(
"FuelBoard provides information to help you compare fuel prices and make informed decisions. We cannot guarantee the accuracy, completeness or availability of fuel price information, savings estimates or recommendations.",
"FuelBoard accepts no responsibility for additional travel costs, financial losses or decisions made using the information provided."
)
} header: {
heading("Disclaimer", systemImage: "info.circle")
}
}
// The brief asks for ~24 pt between topics; the list's own default is
// wider and unsettable, this pins it.
.listSectionSpacing(.custom(24))
.navigationTitle("Important information")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
.presentationDetents([.large])
.presentationBackground(Color(UIColor.systemGroupedBackground))
}
/// A section heading: accent symbol + semibold title, laid out horizontally
/// and vertically centred, with the symbol at a consistent size in every
/// section.
///
/// `.title3` is 20 pt at the default Dynamic Type size — inside the 18–20 pt
/// brief — and still scales with the user's text size, so the symbols stay in
/// proportion to the headings rather than drifting apart on large sizes.
///
/// The symbol is decorative (the heading text already carries the meaning),
/// so it is hidden from VoiceOver to stop it being announced as a stray glyph.
private func heading(_ title: String, systemImage: String) -> some View {
HStack(alignment: .center, spacing: 8) {
Image(systemName: systemImage)
.font(.title3)
.foregroundStyle(Color.accentColor)
// Same-width column for every symbol, so all six headings'
// text starts at one x regardless of how wide the glyph is.
.frame(width: iconSlot)
.accessibilityHidden(true)
Text(LocalizedStringKey(title))
.font(.headline)
// `Color.primary`, not `.primary`: the bare ShapeStyle resolves
// against the List header's own secondary style and comes out
// grey — measured (152,152,159), i.e. identical to the body text,
// which throws away the heading/description hierarchy.
.foregroundStyle(Color.primary)
// Let a long heading wrap instead of truncating on a narrow
// device or at a large text size.
.fixedSize(horizontal: false, vertical: true)
}
}
/// A card's paragraphs, stacked as prose rather than as separate `List` rows
/// so the card reads as one statement and the list draws no hairline between
/// a topic's own sentences.
///
/// `LocalizedStringKey` is what makes a `String` parameter localisable —
/// `Text(someString)` would render verbatim and the copy editor could never
/// reach it.
@ViewBuilder
private func paragraphs(_ lines: String...) -> some View {
VStack(alignment: .leading, spacing: 10) {
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
Text(LocalizedStringKey(line))
}
}
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
/// The row that opens `ImportantInformationSheet`. Reusable: drop it in any
/// `List` section.
///
/// `.buttonStyle(.plain)` keeps it from becoming a row-wide tap target that
/// could swallow a neighbouring control (see the two-buttons-in-one-row rule).
struct ImportantInformationRow: View {
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
Image(systemName: "info.circle")
Text("Important information")
Spacer(minLength: 8)
Image(systemName: "chevron.forward")
.font(.footnote.weight(.semibold))
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
}
}
extension View {
/// Presents the shared "Important information" disclaimer. One call site per
/// surface — the sheet itself is never duplicated.
func importantInformationSheet(isPresented: Binding<Bool>) -> some View {
sheet(isPresented: isPresented) { ImportantInformationSheet() }
}
}
#if DEBUG
/// QA hook support: `-showImportantInfo` opens the Key sheet and then the
/// disclaimer on top of it, so the copy can be screenshotted without a tap
/// (simctl has no tap command) — the same chained pattern as
/// `-showBestValueInfo`. The disclaimer's `.sheet` lives inside the Key sheet's
/// content, so it cannot present while the Key sheet is shut.
enum ImportantInformationQA {
static var isRequested: Bool {
ProcessInfo.processInfo.arguments.contains("-showImportantInfo")
}
}
#endif
+1 -1
View File
@@ -79,7 +79,7 @@ enum LiveActivityManager {
currentStartDate = nil
start(attributes: attributes, state: state)
} else {
Task { await current.update(using: state) }
Task { await current.update(ActivityContent(state: state, staleDate: nil)) }
}
} else {
start(attributes: attributes, state: state)
+2 -2
View File
@@ -111,7 +111,7 @@ struct OnboardingView: View {
.padding(.bottom, 16)
Text(String(localized: "Welcome to\nFuelBoard", comment: "Onboarding welcome title"))
.font(.title.bold())
.font(.largeTitle.bold())
.multilineTextAlignment(.center)
Text(String(localized: "The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data.", comment: "Onboarding welcome subtitle"))
@@ -156,7 +156,7 @@ struct OnboardingView: View {
permissionStatusLabel(
granted: prompter.locationGranted,
denied: prompter.locationDenied,
deniedText: String(localized: "Location was denied. You can still browse all stations, but \"nearest\" sorting needs it.", comment: "Onboarding location denied text")
deniedText: String(localized: "Location was denied. You can still browse all stations, but \"closest\" sorting needs it.", comment: "Onboarding location denied text")
)
.padding(.top, 24)
+10 -3
View File
@@ -13,6 +13,7 @@
// every row at once; the mode must also be ON for any pass to run at all.
import Foundation
import CoreLocation
import MapKit
import UIKit
import WidgetKit
@@ -131,7 +132,7 @@ enum RoadDistanceService {
// visible to show for it: no pass, no requests, no widget reloads.
guard FuelStore.accurateDistancesEnabled else {
await updateDebug(baseDebugStatus(state: .gated,
reason: "Accurate distances off",
reason: "Driving distances off",
stations: stations,
lat: lat,
lng: lng,
@@ -407,8 +408,14 @@ enum RoadDistanceService {
/// 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.source = MKMapItem(
location: CLLocation(latitude: from.latitude, longitude: from.longitude),
address: nil
)
request.destination = MKMapItem(
location: CLLocation(latitude: to.latitude, longitude: to.longitude),
address: nil
)
request.transportType = .automobile
request.requestsAlternateRoutes = false
do {
+145 -49
View File
@@ -20,6 +20,12 @@ struct SettingsView: View {
@Binding var priceDisplayStyle: PriceDisplayStyle
@Binding var stationTapAction: StationTapAction
@Binding var fuelSavingsSettings: FuelSavingsSettings
/// The Best Value gate — the net saving that makes a station worth the trip.
/// Edited here, with the fill size and MPG that PRODUCE that saving, rather
/// than in the Alerts tab: the same figure also decides which station rows
/// carry the BEST VALUE badge, so it is a fuel-savings setting first and an
/// alert setting second. Stored under the alerts key either way.
@Binding var bestValueThresholdGBP: Double
@Binding var dataRefreshMode: DataRefreshMode
/// The fuel + radius currently configured for alerts (mirrors the Alerts
/// tab) so the test notification matches what real alerts will say.
@@ -64,11 +70,21 @@ struct SettingsView: View {
/// Hidden developer flag — the Debug section only appears when on. Toggled
/// by tapping the About → Version row five times (NOT a user-facing switch).
@State private var debugMode: Bool = FuelStore.loadDebugMode()
/// "Accurate distances" — read once on appear and written straight through to
/// "Driving distances" — read once on appear and written straight through to
/// the shared store, which is what every display surface actually consults.
@State private var accurateDistances: Bool = FuelStore.accurateDistancesEnabled
/// Slider drafts — the value under the thumb while it is down. `nil` means
/// "not dragging", in which case the stored setting is shown, so an external
/// change (a unit switch, the in-list control, a QA hook) is picked up with
/// no re-seeding. Dragging writes only the draft; the setting is saved once,
/// when the drag ends — see `commitMPG` / `commitFillUp`.
@State private var mpgDraft: Double?
@State private var fillUpDraft: Double?
@State private var versionTapCount = 0
@State private var lastVersionTap = Date.distantPast
/// The shared "Important information" disclaimer sheet, opened from the row
/// at the bottom of the tab.
@State private var showImportantInformation = false
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
/// type (keychain) + the installed-widget inventory from WidgetCenter.
@State private var smallWidgetDiag: String?
@@ -110,6 +126,9 @@ struct SettingsView: View {
List {
Section {
Toggle("Best Value", isOn: fuelSavingsEnabledBinding)
Text("Find stations that could save you money, even after the cost of driving there.")
.font(.footnote)
.foregroundStyle(.secondary)
// The feature's own controls follow its toggle: switched
// off they grey out but stay on screen (same rule as the
// distance picker in Closest mode) and keep their values, so
@@ -125,11 +144,20 @@ struct SettingsView: View {
.monospacedDigit()
.foregroundStyle(.secondary)
}
Slider(value: fuelSavingsMPGBinding,
// Draft-bound: the thumb moves a local value and the
// setting is written once, when the drag ends. Writing
// durable app-wide state on every step is what made this
// slider stutter.
Slider(value: mpgSliderValue,
in: FuelSavingsCalculator.mpgSliderRange,
step: FuelSavingsCalculator.mpgSliderStep)
step: FuelSavingsCalculator.mpgSliderStep) { editing in
if !editing { commitMPG() }
}
.accessibilityLabel("Average fuel economy")
.accessibilityValue(mpgText)
Text("How many miles your car travels per gallon.")
.font(.footnote)
.foregroundStyle(.secondary)
Picker("Fill-up unit", selection: fuelSavingsUnitBinding) {
ForEach(FuelSavingsFillUnit.allCases) { unit in
Text(unit.displayName).tag(unit)
@@ -137,28 +165,47 @@ struct SettingsView: View {
}
.pickerStyle(.segmented)
HStack {
Text("Typical fill-up")
Text("Fill-up amount")
Spacer()
Text(fillUpText)
.monospacedDigit()
.foregroundStyle(.secondary)
}
Slider(value: fuelSavingsAmountBinding,
Slider(value: fillUpSliderValue,
in: FuelSavingsCalculator.fillAmountSliderRange(for: fuelSavingsSettings.preferredAmount.unit),
step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit))
.accessibilityLabel("Typical fill-up")
step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit)) { editing in
if !editing { commitFillUp() }
}
.accessibilityLabel("Fill-up amount")
.accessibilityValue(fillUpText)
Text(fillUpAmountDescription)
.font(.footnote)
.foregroundStyle(.secondary)
Picker("Minimum saving", selection: $bestValueThresholdGBP) {
ForEach(FuelStore.bestValueAlertThresholdOptionsGBP, id: \.self) { value in
Text(FuelSavingsFormat.money(value)).tag(value)
}
}
// Persisted by ContentView's single
// `.onChange(of: alertsBestValueThresholdGBP)`, which
// also refreshes the alert monitor — so this picker
// needs no handler of its own. The store snaps to the
// nearest option, so the picker and the stored value
// cannot disagree.
Text("The smallest saving you'd consider worth the extra drive.")
.font(.footnote)
.foregroundStyle(.secondary)
if !fuelSavingsSettings.isConfigured && fuelSavingsSettings.enabled {
Text("Set a realistic UK MPG and fill-up amount before Best Value appears in sorting.")
.font(.caption)
.font(.footnote)
.foregroundStyle(.orange)
}
}
.disabled(!fuelSavingsSettings.enabled)
.fuelBoardDisabledState(isEnabled: fuelSavingsSettings.enabled)
} header: {
Text("Fuel Savings")
} footer: {
Text("Best Value estimates fuel cost only, using UK imperial MPG, your typical purchase amount, fuel prices and Apple Maps road distances. Real-world average MPG is better than manufacturer figures; the fill-up amount is your usual purchase, not tank capacity.")
Text("FuelBoard compares fuel prices and travel costs to find stations that could save you money. Results are estimates based on your settings and driving distance.")
}
Section {
@@ -173,6 +220,9 @@ struct SettingsView: View {
WidgetCenter.shared.reloadAllTimelines()
WatchSyncManager.shared.pushSnapshot()
}
Text("Choose how distances are displayed throughout FuelBoard.")
.font(.footnote)
.foregroundStyle(.secondary)
Picker("Price display", selection: $priceDisplayStyle) {
ForEach(PriceDisplayStyle.allCases) { style in
Text(style.displayName).tag(style)
@@ -184,24 +234,26 @@ struct SettingsView: View {
WidgetCenter.shared.reloadAllTimelines()
WatchSyncManager.shared.pushSnapshot()
}
Text("Show prices as 129.9p or £1.299 per litre.")
.font(.footnote)
.foregroundStyle(.secondary)
} header: {
Text("Units")
} footer: {
Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).")
}
Section {
Toggle("Accurate distances", isOn: $accurateDistances)
Toggle("Driving distances", isOn: $accurateDistances)
.onChange(of: accurateDistances) { _, newValue in
FuelStore.setAccurateDistancesEnabled(newValue)
WidgetCenter.shared.reloadAllTimelines()
NotificationCenter.default.post(name: .fuelBoardAccurateDistancesChanged,
object: nil)
}
Text("Use actual driving distances instead of straight-line estimates. Routes are calculated while the app is open.")
.font(.footnote)
.foregroundStyle(.secondary)
} header: {
Text("Distances")
} footer: {
Text("Off (default): distances are straight-line estimates marked Approx. and the app makes no route requests. On: Apple Maps driving distances for every station in range, filled only while this app is open. Search radii then mean miles of driving.")
}
Section {
@@ -214,10 +266,11 @@ struct SettingsView: View {
.onChange(of: stationTapAction) { _, newValue in
FuelStore.saveStationTapAction(newValue)
}
Text("Choose what happens when you tap a station: get directions or view its details.")
.font(.footnote)
.foregroundStyle(.secondary)
} header: {
Text("Tap action")
} footer: {
Text("Tapping a station either opens directions straight away or shows its details first.")
}
Section {
@@ -234,14 +287,12 @@ struct SettingsView: View {
}
VStack(alignment: .leading, spacing: 8) {
Text(dataRefreshMode.summary)
Text("Background checks are best-effort and happen only when iOS allows, so timing is not exact.")
Text("Background updates depend on when your iPhone allows them.")
.foregroundStyle(.secondary)
}
.font(.footnote)
} header: {
Text("Data checking")
} footer: {
Text("Standard saves battery and refreshes price data up to twice daily. Smart checks for newer data more often in the background and only refreshes full prices when an update is available.")
}
Section {
@@ -250,8 +301,9 @@ struct SettingsView: View {
} label: {
Label("Show introduction", systemImage: "arrow.clockwise.circle")
}
} footer: {
Text("Replay the welcome screen, including the location and notification permission prompts.")
Text("View the welcome screens and set up permissions again.")
.font(.footnote)
.foregroundStyle(.secondary)
}
if debugMode {
@@ -285,7 +337,7 @@ struct SettingsView: View {
} header: {
Text("Debug")
} footer: {
Text("The first button applies the real criteria — cheapest \(alertsFuel.displayName.lowercased()) station within \(distanceUnit.format(alertsRadiusKM)) of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (nearest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius.")
Text("The first button applies the real criteria — cheapest \(alertsFuel.displayName.lowercased()) station within \(distanceUnit.format(alertsRadiusKM)) of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (closest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius.")
}
.onAppear { onRefreshDebugStatus() }
@@ -428,7 +480,7 @@ struct SettingsView: View {
} header: {
Text("Road distance fill")
} footer: {
Text("Shows whether Accurate distances is running or gated. Distances stay Approx. until Ready is yes for the current origin; gated reasons explain why no fill is happening.")
Text("Shows whether Driving distances is running or gated. Distances stay Approx. until Ready is yes for the current origin; gated reasons explain why no fill is happening.")
}
Section {
@@ -533,7 +585,7 @@ struct SettingsView: View {
)
}
.buttonStyle(.plain)
.disabled(tipStore.purchaseInProgress)
.fuelBoardDisabledState(isEnabled: !tipStore.purchaseInProgress)
}
}
.listRowBackground(Color.clear)
@@ -558,11 +610,16 @@ struct SettingsView: View {
Text("About")
}
Section {
ImportantInformationRow { showImportantInformation = true }
}
}
// Numeric entry must be escapable: `.decimalPad` has no Return key.
.keyboardDoneButton { focusedSavingsField = nil }
.scrollDismissesKeyboard(.interactively)
.navigationTitle("Settings")
.importantInformationSheet(isPresented: $showImportantInformation)
.onAppear {
Task { await tipStore.load() }
}
@@ -684,59 +741,98 @@ struct SettingsView: View {
private var fuelSavingsEnabledBinding: Binding<Bool> {
Binding(
get: { fuelSavingsSettings.enabled },
set: { fuelSavingsSettings.enabled = $0; persistFuelSavingsSettings() }
set: { fuelSavingsSettings.enabled = $0; saveFuelSavingsField(.enabled) }
)
}
private var fuelSavingsMPGBinding: Binding<Double> {
/// Slider values, draft-first: while the thumb is down the draft is what the
/// control and its read-out show; with no drag in progress the store's value
/// shows, so any external change (unit switch, in-list control, QA hook) is
/// reflected without re-seeding anything.
private var mpgSliderValue: Binding<Double> {
Binding(
get: { fuelSavingsSettings.mpg },
set: { fuelSavingsSettings.mpg = min(max($0, 0), FuelSavingsCalculator.maximumMPG); persistFuelSavingsSettings() }
get: { mpgDraft ?? fuelSavingsSettings.mpg },
set: { mpgDraft = $0 }
)
}
private var fuelSavingsAmountBinding: Binding<Double> {
private var fillUpSliderValue: Binding<Double> {
Binding(
get: { fuelSavingsSettings.preferredAmount.value },
set: {
fuelSavingsSettings.preferredAmount.value = min(max($0, 0), FuelSavingsCalculator.maximumFillAmount(for: fuelSavingsSettings.preferredAmount.unit))
persistFuelSavingsSettings()
}
get: { fillUpDraft ?? fuelSavingsSettings.preferredAmount.value },
set: { fillUpDraft = $0 }
)
}
/// The drag has ended: write the value once, and only if it actually moved.
/// The draft is cleared either way, so the control falls back to the stored
/// setting and shows what was saved — the clamps can lower it.
private func commitMPG() {
defer { mpgDraft = nil }
guard let draft = mpgDraft else { return }
let mpg = min(max(draft, 0), FuelSavingsCalculator.maximumMPG)
guard mpg != fuelSavingsSettings.mpg else { return }
fuelSavingsSettings.mpg = mpg
saveFuelSavingsField(.mpg)
}
private func commitFillUp() {
defer { fillUpDraft = nil }
guard let draft = fillUpDraft else { return }
let amount = min(max(draft, 0),
FuelSavingsCalculator.maximumFillAmount(for: fuelSavingsSettings.preferredAmount.unit))
guard amount != fuelSavingsSettings.preferredAmount.value else { return }
fuelSavingsSettings.preferredAmount.value = amount
saveFuelSavingsField(.amount)
}
/// Persists the one field that changed. Saving the whole settings object
/// rewrites all four keys, which is only right when more than one has moved.
private func saveFuelSavingsField(_ field: FuelStore.FuelSavingsField) {
FuelStore.saveFuelSavingsField(field, from: fuelSavingsSettings)
}
private var fuelSavingsUnitBinding: Binding<FuelSavingsFillUnit> {
Binding(
get: { fuelSavingsSettings.preferredAmount.unit },
set: { newUnit in
fuelSavingsSettings.preferredAmount.unit = newUnit
if !fuelSavingsSettings.preferredAmount.isValid {
fuelSavingsSettings.preferredAmount.value = newUnit == .pounds ? 40 : 30
}
persistFuelSavingsSettings()
guard newUnit != fuelSavingsSettings.preferredAmount.unit else { return }
fuelSavingsSettings.preferredAmount = FuelSavingsCalculator.newComparisonAmount(
afterSwitching: fuelSavingsSettings.preferredAmount, to: newUnit)
// The unit and the amount move together, so both keys are written.
saveFuelSavingsField(.unit)
saveFuelSavingsField(.amount)
// A drag in progress on the old scale no longer maps to this one.
fillUpDraft = nil
}
)
}
/// Describes the fill-up amount in the unit the user picked, so the copy stays
/// true whether the amount is a budget in pounds or a volume in litres.
private var fillUpAmountDescription: String {
switch fuelSavingsSettings.preferredAmount.unit {
case .pounds: return "How much you plan to spend on fuel."
case .litres: return "How many litres you usually buy."
}
}
/// "45 MPG" / "45.5 MPG". The slider is the only control, so this read-out
/// is the one place the figure is stated.
/// is the one place the figure is stated. It follows the draft while the
/// thumb is down.
private var mpgText: String {
let mpg = fuelSavingsSettings.mpg
let mpg = mpgDraft ?? fuelSavingsSettings.mpg
return mpg.rounded() == mpg ? String(format: "%.0f MPG", mpg) : String(format: "%.1f MPG", mpg)
}
/// "£40" / "40 L" — the fill-up slider's read-out.
/// "£40" / "40 L" — the fill-up slider's read-out, draft-first like the MPG one.
private var fillUpText: String {
let value = fillUpDraft ?? fuelSavingsSettings.preferredAmount.value
switch fuelSavingsSettings.preferredAmount.unit {
case .pounds: return String(format: "£%.0f", fuelSavingsSettings.preferredAmount.value)
case .litres: return String(format: "%.0f L", fuelSavingsSettings.preferredAmount.value)
case .pounds: return String(format: "£%.0f", value)
case .litres: return String(format: "%.0f L", value)
}
}
private func persistFuelSavingsSettings() {
FuelStore.saveFuelSavingsSettings(fuelSavingsSettings)
}
/// "API" when the relay is serving the official Fuel Finder API, "CSV"
/// when it fell back to the public mirror, "—" when unknown/never fetched.
private var connectionText: String {
+283
View File
@@ -0,0 +1,283 @@
import SwiftUI
/// The More info sheet's facts row: the selected fuel's price on the left, the
/// distance on the right, separated by a hairline down the middle.
///
/// Every figure comes from the sheet's existing sources — the price through
/// `FuelStore.priceText` and the distance through
/// `FuelStore.stationDistanceDisplay(...)` — so this row can never disagree with
/// the listing row, the widget or the Live Activity, and both columns keep the
/// shared "Approx." confidence marking.
struct StationFactsRow: View {
let station: FuelStation
let selectedFuel: FuelType
let priceAge: FuelPriceFreshness.Assessment
let distance: StationDistanceDisplay?
let distanceUnit: DistanceUnit
/// The user's price display style, from the same Settings choice the listing
/// rows and the widgets read.
var priceStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
/// The price's point size, scaled with Dynamic Type: the price is built from
/// an explicit size (its "/L" suffix is sized relative to it inside
/// `priceTextAttributed`), so it needs a metric rather than a font style.
/// 22pt matches the listing row's price.
@ScaledMetric(relativeTo: .title2) private var priceSize: CGFloat = 22
var body: some View {
HStack(alignment: .top, spacing: 12) {
fuelColumn
StationFactsDivider()
distanceColumn
}
.padding(.vertical, 2)
}
private var fuelColumn: some View {
VStack(alignment: .leading, spacing: 3) {
Text(selectedFuel.displayName)
.font(.caption)
.foregroundStyle(.secondary)
priceView
StationPriceAgeView(assessment: priceAge, compact: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
private var distanceColumn: some View {
VStack(alignment: .leading, spacing: 3) {
Text("Distance")
.font(.caption)
.foregroundStyle(.secondary)
Text(distanceText)
.font(.title2.weight(.bold))
.monospacedDigit()
Text(returnText)
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .combine)
}
/// The selected grade's price, in the user's chosen display style — the same
/// `priceTextAttributed` the listing row uses, so the sheet prints the bare
/// forecourt figure ("167.9") in Station sign and the superscripted
/// "£1.67⁹/L" in Pounds & pence, and the two surfaces cannot disagree.
/// The returned Text carries its own fonts, so no `.font(...)` goes on top.
///
/// A missing grade shows an em dash rather than hiding the column, so the row
/// keeps its two-column shape.
@ViewBuilder
private var priceView: some View {
if let price = station.prices[selectedFuel] {