Compare commits

...
Author SHA1 Message Date
FuelBoard Contributor 29e299d558 Default station taps to the detail sheet
The default tap action was Directions: tapping a row handed straight off to
Apple Maps. That leaves the app with no way back, and on a fresh install it
hides everything the row already knows — address, distance, the Worth the Drive
breakdown — behind a jump to another app. The detail sheet shows the same
station with Directions one tap away inside it, so it is the better default to
land on. Directions remains available, both in the sheet and by picking it in
Settings.

The fallback in loadStationTapAction is the whole change. Every consumer — row
taps, the notification path in ProximityMonitor, Station.tapDestinationURL for
widgets — already resolves the setting at the point of use rather than caching
it, so none needed touching and none can drift away from the others.

An explicit choice is not overridden: the fallback applies only when nothing is
stored, and the only writer is the Settings picker. Anyone who deliberately
chose Directions keeps it.

Comments that existed to explain the old default are corrected rather than left
contradicting the code — the section comment and the enum doc in FuelStore, and
the FB_DETAIL_DEBUG hook's rationale in ContentView. That hook is now near
redundant since the default is the sheet; it is kept because it still overrides
a stored Directions on a simulator.

The default itself is still untested: loadStationTapAction reads the keychain
before defaults, and the SwiftPM suite never touches the keychain today, so a
test would risk an access prompt hanging the run.

Tests: 278 pass.
2026-10-19 16:28:47 +01:00
FuelBoard Contributor fcd1fb8572 Use the location pointer for the Closest label
The Closest label was drawing location.north.fill. A north arrow reads as a
bearing — it implies direction of travel toward something, which is the wrong
idea for a label that means "this is the station nearest you". The plain
location pointer says where a place is without implying a heading, and it is
already the glyph the app's other location affordances draw in Settings and on
the map.

One constant, as before: StationLabelKind.closest.symbol. The badge, the Key
legend and both verdict symbols reference it, so nothing else needed touching.

The test that pins each label's glyph now expects "location"; its north-arrow
expectation was the only failure in the suite. The comment above the verdict
symbol still described a north arrow and now describes the pointer.

Also bumps CURRENT_PROJECT_VERSION 30 -> 31 for this device install round.

Tests: 278 pass.
2026-10-19 15:20:44 +01:00
FuelBoard Contributor 8153a36168 Make Closest a station label and retire Top
Top and Cheapest were two words for one state: both meant "the lowest price
among the stations on screen", differing only in the candidate set (radius vs
Favourites). Which word a surface used had drifted by device — the phone's
rows said Cheapest while the watch said Top for the identical state. Top is
gone from the enum, the Key legend, the badge and the watch's hand-rolled
label; Cheapest survives because it names the measure and is already the word
the favourites copy uses.

Closest is now a real station label rather than a verdict. StationRecommendationBadge
gains .closest with a total precedence — Best value, then Cheapest, then Closest
— because a row has room for one label and proximity is the weakest claim: a
short drive says nothing about the price, and unlike the other two it reports no
saving at all. The badge sits above the price, where the other two sit. The
closest row's verdict capsule is suppressed: its only word for that state was
"Closest", so printing it below as well would state the same fact twice, three
lines apart. Other rows keep their Saves / Break even / Costs capsules unchanged.

"Closest station" becomes "Closest", read from the single
FuelSavingsResult.baselineHeadline constant so the badge, the verdict and the Key
cannot word it differently. The Key legend renders all three station labels with
the shared StationLabelBadge; the old KeyStationLabelIndicator, which
special-cased the closest station to a wider verdict pill so it read as a
different class of thing from the two above it, is deleted.

One closest icon everywhere. The listing and detail verdict symbols used
location.circle and location.circle.fill for the state the Key taught as
location.north.fill; both now read StationLabelKind.closest.symbol, so the three
surfaces resolve one glyph from one place.

The detail sheet gains the Closest capsule below the map, ordered to match the
Key. Favourites gains the nearest favourite as its Closest, measured on the same
one-way distance its rows print and restricted to favourites with a price for the
active fuel — matching the radius tab, where the closest station is by definition
the savings baseline.

closestStationID flows from the one scaffold baseline the savings lookup already
compares against, so a row's badge and the reference station cannot disagree.
No savings calculation, threshold, ranking, eligibility or station-selection
logic changed.

The Key sheet's detent is now bound rather than fixed, so dragging it up to read
the Station labels section sticks instead of snapping back; a QA launch argument
opens it at full height, since the 0.6 detent puts that section below the fold on
every device. That argument is read unconditionally, unlike the other harness
hooks which are #if DEBUG-gated.

Tests: 278 pass, 9 new — the label precedence ladder across every combination,
capsule order in the sheet, Closest alone, and the retired label pinned absent.
2026-10-19 14:59:47 +01:00
FuelBoard Contributor c69bdb3c5f Show ranking capsules and value breakdown in station details
The More info sheet now lists every ranking a station qualifies for, directly under the map and above the price and distance: the same StationLabelBadge the listing rows render, in a flow layout so two or three capsules wrap to a new line rather than truncating at large Dynamic Type.

The rules are not restated. StationRanking defers to StationRecommendationBadge.badge for the Best Value gate, and takes Cheapest and Top as the flags the host list already computed for its own rows, so the sheet cannot award a capsule the row behind it withheld. A host passes one of the two, never both: Stations sends isCheapestInRange, Favourites sends isTopFavourite. No calculation, ranking, eligibility or savings logic changed.

Rename the card heading Best Value to Value breakdown, since the working behind it can end in Costs rather than a saving, and drop the green section tint so the card matches the neutral background used elsewhere in the sheet. The Saves, Break even and Costs capsules and every breakdown row are untouched.

Rename the explanation link to How does FuelBoard calculate value? It names the calculation rather than the feature, of which the Best Value gate is only the last step. The sheet's own title stays short: the full question truncated to 'How does FuelBoard...' as a navigation title at both inline and large display modes, so the row carries the question and the sheet names the topic.

Tests: 269 pass, 5 new covering capsule selection, order and sheet-vs-row agreement on Best Value.
2026-10-19 13:20:59 +01:00
FuelBoard Contributor 5d9e71f7ac Unify capsule, station label and price rating styling
One adaptive tone palette and one shape rule now drive every capsule on
Stations, Favourites, the detail sheet and the Key.

- CapsuleTone: the light/dark palette pairs in one place, resolved through a
  UIColor dynamic provider so a List row that re-renders without rebuilding
  its body still follows an appearance switch. Every pair clears 4.5:1
  foreground-on-background in both appearances (4.96-6.72:1).
- AdaptiveCapsule: capsule at one drawn line, rounded rect at two or more,
  radius 8 -> 10, measuring content before padding so insets cannot inflate
  the line count.
- Listing verdict capsules move from white-on-muted-solid to the tinted
  palette, so Saves, Break even, Costs and Closest station read the same in
  the rows and in the detail sheet.
- Best Value is purple, not green: green stays reserved for a financial
  saving, which is what the Saves capsule beside it means.
- Price dots standardise to 12pt in the app (widget keeps its own 6pt dot;
  PriceRatingDot now takes a diameter so the difference is deliberate).
- Station labels are sentence case with their recommended symbols (medal, tag,
  location.north.fill, trophy) and StationLabelKind replaces the Key's
  string-literal dispatch, so renaming a label can no longer drop a row.
- Key says "Costs", matching the capsule's own headline (was "Costs more").
2026-10-19 11:39:54 +01:00
FuelBoard Contributor fcb11a8f52 Bump the build number to 30
29 was used for two different binaries during the listing refinement, so
the next install would have been indistinguishable from the last one.
Raised in all eight configurations — app, widget, watch app and watch
extension, Debug and Release — so the whole bundle reports one number.
2026-10-18 20:18:00 +01:00
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
28 changed files with 2875 additions and 807 deletions
+18
View File
@@ -106,6 +106,24 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
scene (CPListTemplate) for navigation deep-links. scene (CPListTemplate) for navigation deep-links.
- [ ] **Alert region tuning** — make alert trigger radius + dedup window - [ ] **Alert region tuning** — make alert trigger radius + dedup window
user-configurable (currently fixed 300 m trigger / 1 per station per hour). 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 - [ ] **Price-drop alerts: threshold control + Check now** — keep the current
cache-first / 12 h refresh policy, but add a per-alert sensitivity control 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: 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; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements; CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27; CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -599,7 +599,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements; CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27; CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -663,7 +663,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27; CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -728,7 +728,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27; CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -826,7 +826,7 @@
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27; CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/App-Info.plist"; INFOPLIST_FILE = "Config/App-Info.plist";
@@ -851,7 +851,7 @@
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27; CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/App-Info.plist"; INFOPLIST_FILE = "Config/App-Info.plist";
@@ -875,7 +875,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27; CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/Widget-Info.plist"; INFOPLIST_FILE = "Config/Widget-Info.plist";
@@ -900,7 +900,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements; CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 27; CURRENT_PROJECT_VERSION = 31;
DEVELOPMENT_TEAM = F3BE6NE7U3; DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/Widget-Info.plist"; INFOPLIST_FILE = "Config/Widget-Info.plist";
+110 -62
View File
@@ -6,18 +6,12 @@ import SwiftUI
/// versa). The Live Activity section also lives here — it mirrors this page's /// versa). The Live Activity section also lives here — it mirrors this page's
/// shape (toggle + fuel + distance) so the two "notify me while driving" /// shape (toggle + fuel + distance) so the two "notify me while driving"
/// surfaces sit together. Geofence plumbing (count, 18-limit) lives in /// surfaces sit together. Geofence plumbing (count, 18-limit) lives in
/// Settings → Debug; the user-facing page keeps a plain-language explainer /// Settings → Debug; the user-facing page keeps short descriptions of the
/// plus a 0-stations hint. /// cheapest-station rule and radius, plus a 0-stations hint.
struct AlertsView: View { struct AlertsView: View {
@Binding var enabled: Bool @Binding var enabled: Bool
@Binding var radius: Double // stored in km (monitor + storage) @Binding var radius: Double // stored in km (monitor + storage)
@Binding var fuel: FuelType // the fuel alerts monitor for @Binding var fuel: FuelType // the fuel alerts monitor for
@Binding var alertMode: AlertMode
/// Read-only here: the threshold is edited in Settings → Fuel Savings, where
/// the fill size and MPG that decide the saving live. Read back so the
/// alert's own rule is visible without leaving the tab.
let bestValueThresholdGBP: Double
let bestValueConfigured: Bool
@Binding var favouriteDropEnabled: Bool @Binding var favouriteDropEnabled: Bool
@Binding var favouriteDropFuel: FuelType @Binding var favouriteDropFuel: FuelType
/// Whether alerts mirror the Stations-tab search distance (capped at 8 mi). /// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
@@ -55,31 +49,29 @@ struct AlertsView: View {
return String(format: String(localized: "%lld %@", comment: "Distance label"), shown, distanceUnit.label(for: Double(shown))) return String(format: String(localized: "%lld %@", comment: "Distance label"), shown, distanceUnit.label(for: Double(shown)))
} }
/// Situational guidance for the currently-selected alert radius, shown /// The distance the geofence actually caps "Follow search" at, in miles
/// under the picker so users know what each choice is best for. Labels /// (FuelStore stores the cap in km so the monitor and storage are unit-free).
/// mirror the picker row text (unit-aware) and explain the trade-off: private var followSearchCapMiles: Int {
/// smaller radii ping more often but later; larger radii ping rarely in Int((FuelStore.alertFollowCapKM / FuelSavingsCalculator.kilometresPerMile).rounded())
/// town but earlier from afar. }
private var alertRadiusGuidance: String {
/// 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 { switch alertChoice.wrappedValue {
case .followSearch: 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") let capped = stationLimit > followSearchCapMiles
case .fixed(let value): let label = distanceLabel(forMiles: capped ? followSearchCapMiles : stationLimit)
let optionLabel = String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value))) if capped {
let body: String 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)
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")
} }
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)
} }
} }
@@ -136,71 +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 { var body: some View {
NavigationStack { NavigationStack {
List { List {
Section { Section {
Toggle(String(localized: "Cheapest favourite", comment: "Favourite price-drop alert toggle"), isOn: $favouriteDropEnabled) 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) { Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $favouriteDropFuel) {
ForEach(FuelType.allCases) { fuel in ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel) Text(fuel.displayName).tag(fuel)
} }
} }
}
Button(String(localized: "Check now", comment: "Manual favourite price-drop check button"), action: onCheckFavouriteDropNow) 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: { } header: {
Text(String(localized: "Price-drop alerts", comment: "Favourite price-drop alerts section title")) Text(String(localized: "Favourite price alerts", comment: "Favourite price 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"))
} }
Section { Section {
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled) Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
Picker(String(localized: "Alert type", comment: "Proximity alert type picker title"), selection: $alertMode) { Group {
ForEach(AlertMode.allCases.filter { mode in mode != .bestValue || bestValueConfigured }) { mode in Text(String(localized: "Get notified when you're near one of the cheapest stations for your chosen fuel.", comment: "Cheapest station proximity alert description"))
Text(mode.displayName).tag(mode) .font(.footnote)
}
}
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)
.foregroundStyle(.secondary) .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) { Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $fuel) {
ForEach(FuelType.allCases) { fuel in ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel) 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) { 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) 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 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(String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))).tag(AlertRadiusChoice.fixed(value))
} }
} }
Text(alertRadiusGuidance)
.font(.caption)
.foregroundStyle(.secondary)
if alertMode == .bestValue {
// The threshold is edited in Settings → Fuel Savings: it
// labels station rows BEST VALUE as well as gating this
// alert, so it lives with the fill size and MPG that
// produce the saving. Read back here, not offered twice.
Text(String(format: String(localized: "Minimum saving %@ — change in Settings, Fuel Savings.", comment: "Best Value alert threshold read-back"), FuelSavingsFormat.money(bestValueThresholdGBP)))
.font(.caption)
.foregroundStyle(.secondary)
} }
Text(alertRadiusDescription)
.font(.footnote)
.foregroundStyle(.secondary)
if enabled && monitoredCount == 0 { if enabled && monitoredCount == 0 {
Text(String(localized: "No stations monitored yet — open the Stations tab to load prices first.", comment: "No monitored stations hint")) 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) .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: { } header: {
Text(String(localized: "Proximity alerts", comment: "Proximity alerts section title")) 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 { if enabled, let lastAlert {
@@ -212,21 +243,38 @@ struct AlertsView: View {
Section { Section {
Toggle(String(localized: "Cheapest station", comment: "Live Activity toggle"), isOn: $liveActivityEnabled) 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) { Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $liveActivityFuel) {
ForEach(FuelType.allCases) { fuel in ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel) 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) { 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) 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 ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
Text(distanceLabel(forMiles: miles)).tag(LADistanceChoice.fixed(miles)) 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: { } header: {
Text(String(localized: "Live Activity", comment: "Live Activity section title")) 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")) .navigationTitle(String(localized: "Alerts", comment: "Alerts tab title"))
+1 -1
View File
@@ -83,7 +83,7 @@ struct BestValueIntroContent: View {
.pickerStyle(.segmented) .pickerStyle(.segmented)
slider( 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, readOut: fillUpText,
value: amountBinding, value: amountBinding,
range: FuelSavingsCalculator.fillAmountSliderRange(for: settings.preferredAmount.unit), range: FuelSavingsCalculator.fillAmountSliderRange(for: settings.preferredAmount.unit),
+247
View File
@@ -0,0 +1,247 @@
import SwiftUI
/// One semantic capsule tone: a foreground and a background for each appearance.
///
/// The palette is deliberately a TINTED pair rather than a saturated solid. Every
/// tone below clears 4.5:1 for its own foreground on its own background in BOTH
/// appearances (measured, 4.96:1 to 6.72:1), which is what lets one component
/// carry the same meaning on a white row and a dark card without a second
/// colour per status.
///
/// The pairs are resolved through `UIColor`'s dynamic provider rather than
/// SwiftUI's `@Environment(\.colorScheme)`: a capsule inside a `List` is often
/// re-rendered without its body being rebuilt, so a scheme read at body time can
/// lag one switch behind. This way the colour itself follows the appearance.
struct CapsuleTone: Equatable {
let lightForeground: Color
let lightBackground: Color
let darkForeground: Color
let darkBackground: Color
var foreground: Color { dynamic(light: lightForeground, dark: darkForeground) }
var background: Color { dynamic(light: lightBackground, dark: darkBackground) }
private func dynamic(light: Color, dark: Color) -> Color {
let lightUI = UIColor(light)
let darkUI = UIColor(dark)
return Color(UIColor { traits in
traits.userInterfaceStyle == .dark ? darkUI : lightUI
})
}
}
extension CapsuleTone {
/// Green: a financial saving.
static let saves = CapsuleTone(
lightForeground: Color(hex: 0x176B3D), lightBackground: Color(hex: 0xDDF3E5),
darkForeground: Color(hex: 0x66D69A), darkBackground: Color(hex: 0x204333)
)
/// Grey: a neutral outcome, neither saving nor costing.
static let breakEven = CapsuleTone(
lightForeground: Color(hex: 0x555B64), lightBackground: Color(hex: 0xE9EAED),
darkForeground: Color(hex: 0xD0D0D5), darkBackground: Color(hex: 0x404044)
)
/// Red: an additional cost.
static let costs = CapsuleTone(
lightForeground: Color(hex: 0xA63F3D), lightBackground: Color(hex: 0xF9E1DF),
darkForeground: Color(hex: 0xEF9690), darkBackground: Color(hex: 0x4B3032)
)
/// Purple: best overall value after travel.
static let bestValue = CapsuleTone(
lightForeground: Color(hex: 0x7043A1), lightBackground: Color(hex: 0xEEE4F8),
darkForeground: Color(hex: 0xD1B4FA), darkBackground: Color(hex: 0x44354F)
)
/// Blue: the lowest fuel price in range.
static let cheapest = CapsuleTone(
lightForeground: Color(hex: 0x225F9B), lightBackground: Color(hex: 0xE0EFFB),
darkForeground: Color(hex: 0x8BC5FA), darkBackground: Color(hex: 0x293F53)
)
/// Neutral grey: proximity. Same family as `breakEven` — both mean "no
/// judgement" — but kept as its own pair so proximity and a break-even
/// verdict stay independently tunable.
static let closest = CapsuleTone(
lightForeground: Color(hex: 0x525967), lightBackground: Color(hex: 0xE8EAF0),
darkForeground: Color(hex: 0xC0C3CC), darkBackground: Color(hex: 0x424349)
)
}
/// The one shape rule every capsule in the app follows.
///
/// A capsule while the content is a single line; a rounded rectangle once it
/// wraps. A pill's semicircular ends crowd the text corners at two lines or
/// more, while a 10pt radius does not. The shape follows what the content
/// actually DREW, not how many lines it declared, because a label can wrap in a
/// narrow column without the data changing — the baseline label was the case that
/// forced this: "Closest station" was one declared line that wrapped to two in the
/// listing's price column, which is also why that label is now the single word
/// "Closest".
///
/// Measuring happens on the content BEFORE padding, so the drawn line count is
/// never inflated by the capsule's own insets.
struct AdaptiveCapsule<Content: View>: View {
/// The tone's background fill. The caller resolves it from its own semantics.
let fill: Color
/// One line's height for the content's font, already scaled with Dynamic
/// Type — pass a `@ScaledMetric` so the division below tracks the user's size.
let lineHeight: CGFloat
/// Lines the content declares, used for the first layout pass before a
/// measured height exists.
var declaredLineCount: Int = 1
var cornerRadius: CGFloat = 10
var horizontalPadding: CGFloat
var verticalPadding: CGFloat
@ViewBuilder let content: Content
@State private var drawnHeight: CGFloat = 0
private var lineCount: Int {
guard drawnHeight > 0, lineHeight > 0 else { return max(1, declaredLineCount) }
return max(1, Int((drawnHeight / lineHeight).rounded()))
}
private var shape: AnyShape {
lineCount > 1
? AnyShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
: AnyShape(Capsule())
}
var body: some View {
content
.background(
GeometryReader { proxy in
Color.clear.preference(key: CapsuleContentHeightKey.self,
value: proxy.size.height)
}
)
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
.background(shape.fill(fill))
.onPreferenceChange(CapsuleContentHeightKey.self) { drawnHeight = $0 }
}
}
/// Carries the drawn height of a capsule's content up to `lineCount`.
private struct CapsuleContentHeightKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
extension Color {
/// A fixed sRGB colour from a 24-bit literal. The capsule palette is
/// appearance-specific and supplied per tone, so these are deliberately NOT
/// dynamic — `CapsuleTone` resolves the pair itself.
init(hex: UInt32) {
self.init(
.sRGB,
red: Double((hex >> 16) & 0xFF) / 255,
green: Double((hex >> 8) & 0xFF) / 255,
blue: Double(hex & 0xFF) / 255,
opacity: 1
)
}
}
/// Lays its children left to right and wraps to a new line when the next one
/// would overflow, so two or three capsules never squeeze or truncate each other
/// on a narrow screen or at large Dynamic Type.
///
/// A flow layout rather than `ViewThatFits`: a fallback would stack the capsules
/// vertically as soon as one did not fit, which reads as three separate labels
/// rather than one row of rankings. This keeps them on a line while they fit and
/// only moves the overflowing one down.
///
/// Children are measured with `.unspecified` width so a long label takes the width
/// it needs (the badge sets `fixedSize` horizontally) instead of being clipped to
/// the container.
struct CapsuleFlowLayout: Layout {
var spacing: CGFloat = 6
var lineSpacing: CGFloat = 6
private struct Row {
var range: Range<Int>
var width: CGFloat
var height: CGFloat
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let maxWidth = proposal.width ?? .infinity
let rows = rows(maxWidth: maxWidth, subviews: subviews)
let width = rows.map(\.width).max() ?? 0
let height = rows.reduce(0) { $0 + $1.height }
+ CGFloat(max(0, rows.count - 1)) * lineSpacing
return CGSize(width: min(width, maxWidth), height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
var y = bounds.minY
for row in rows(maxWidth: bounds.width, subviews: subviews) {
var x = bounds.minX
for index in row.range {
let size = subviews[index].sizeThatFits(.unspecified)
subviews[index].place(
at: CGPoint(x: x, y: y),
anchor: .topLeading,
proposal: ProposedViewSize(size)
)
x += size.width + spacing
}
y += row.height + lineSpacing
}
}
private func rows(maxWidth: CGFloat, subviews: Subviews) -> [Row] {
var rows: [Row] = []
var start = 0
var used: CGFloat = 0
var height: CGFloat = 0
for index in subviews.indices {
let size = subviews[index].sizeThatFits(.unspecified)
let needed = used == 0 ? size.width : used + spacing + size.width
// `used > 0` guards the first child: a single capsule wider than the
// container must still be placed (and may wrap its own text) rather
// than starting an empty line above itself.
if used > 0, needed > maxWidth {
rows.append(Row(range: start..<index, width: used, height: height))
start = index
used = size.width
height = size.height
} else {
used = needed
height = max(height, size.height)
}
}
if start < subviews.count {
rows.append(Row(range: start..<subviews.count, width: used, height: height))
}
return rows
}
}
/// The More info sheet's row of ranking capsules: every label the station
/// qualifies for, wrapped when the screen is narrow.
///
/// Renders as the SAME `StationLabelBadge` the listing rows use — same tone, same
/// symbol, same sentence-case copy — so a station carrying "Best value" here and
/// on the Stations list cannot look like two different things.
struct StationRankingCapsules: View {
let kinds: [StationLabelKind]
var body: some View {
CapsuleFlowLayout(spacing: 6, lineSpacing: 6) {
ForEach(kinds, id: \.self) { kind in
StationLabelBadge(kind: kind)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
// One phrase per capsule, read in the order they are shown.
.accessibilityElement(children: .contain)
}
}
+117 -118
View File
@@ -31,13 +31,12 @@ struct ContentView: View {
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit() @State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
@State private var priceDisplayStyle: PriceDisplayStyle = ContentView.qaPriceStyleOverride(default: FuelStore.loadPriceDisplayStyle()) @State private var priceDisplayStyle: PriceDisplayStyle = ContentView.qaPriceStyleOverride(default: FuelStore.loadPriceDisplayStyle())
@State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction() @State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
@State private var fuelSavingsSettings: FuelSavingsSettings = FuelStore.loadFuelSavingsSettings() @State private var fuelSavingsSettings: FuelSavingsSettings = ContentView.qaSavingsSettingsOverride(default: FuelStore.loadFuelSavingsSettings())
@State private var sessionFuelSavingsAmount: FuelSavingsFillAmount = FuelStore.loadFuelSavingsSettings().preferredAmount @State private var sessionFuelSavingsAmount: FuelSavingsFillAmount = ContentView.qaSavingsSettingsOverride(default: FuelStore.loadFuelSavingsSettings()).preferredAmount
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites() @State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled() @State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius() @State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
@State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel() @State private var alertsFuel: FuelType = FuelStore.loadAlertsFuel()
@State private var alertsMode: AlertMode = FuelStore.loadAlertsMode()
@State private var alertsBestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP() @State private var alertsBestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
@State private var favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled() @State private var favouriteDropAlertsEnabled: Bool = FuelStore.loadFavouriteDropAlertsEnabled()
@State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel() @State private var favouriteDropAlertsFuel: FuelType = FuelStore.loadFavouriteDropAlertsFuel()
@@ -152,29 +151,40 @@ struct ContentView: View {
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius) 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) { private func updateAlertMonitor(fuel overrideFuel: FuelType? = nil) {
monitor.update( monitor.update(
stations: stations, stations: stations,
favourites: refreshedFavourites, favourites: refreshedFavourites,
fuel: overrideFuel ?? alertsFuel, fuel: overrideFuel ?? alertsFuel,
radiusKM: effectiveAlertsRadiusKM, radiusKM: effectiveAlertsRadiusKM,
alertMode: effectiveAlertsMode, alertMode: .cheapest,
bestValueThresholdGBP: alertsBestValueThresholdGBP, bestValueThresholdGBP: alertsBestValueThresholdGBP,
fuelSavingsSettings: fuelSavingsSettings, fuelSavingsSettings: fuelSavingsSettings,
fuelSavingsAmount: sessionFuelSavingsAmount 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() { private func installLocationUpdateHook() {
locationManager.onLocationUpdate = { locationManager.onLocationUpdate = {
self.updateAlertMonitor() self.updateAlertMonitor()
@@ -311,29 +321,29 @@ struct ContentView: View {
return pool.compactMap { $0.prices[selectedFuel] }.min() return pool.compactMap { $0.prices[selectedFuel] }.min()
} }
/// The "best" station — the cheapest in the pool — gets the TOP badge. /// The cheapest station in the pool — the one that wears the Cheapest badge.
/// First occurrence in display order wins on price ties. /// First occurrence in display order wins on price ties.
private var topStationID: String? { private var cheapestStationID: String? {
guard let baselinePrice else { return nil } guard let baselinePrice else { return nil }
return topStationID(in: displayedStations, baselinePrice: baselinePrice) return cheapestStationID(in: displayedStations, baselinePrice: baselinePrice)
} }
/// The TOP-badge station within an ALREADY-SORTED list. Split out so a caller /// The Cheapest-badge station within an ALREADY-SORTED list. Split out so a
/// that has the sorted list (the Stations tab) does not sort the whole /// caller that has the sorted list (the Stations tab) does not sort the whole
/// country a second time just to find one id. /// country a second time just to find one id.
private func topStationID(in list: [FuelStation], baselinePrice: Double) -> String? { private func cheapestStationID(in list: [FuelStation], baselinePrice: Double) -> String? {
list.first { $0.prices[selectedFuel] == baselinePrice }?.id list.first { $0.prices[selectedFuel] == baselinePrice }?.id
} }
/// The app's own "cheapest in range" — the TOP-badge station — mirrored /// The app's own "cheapest in range" — the Cheapest-badge station — mirrored
/// into the Settings → Debug section so its "Cheapest in range" row /// into the Settings → Debug section so its "Cheapest in range" row
/// matches the app list (and the widget when the widget's fuel + distance /// matches the app list (and the widget when the widget's fuel + distance
/// match the app's). The debug section previously showed the ALERT /// match the app's). The debug section previously showed the ALERT
/// prediction (alertsFuel + alert radius), which is a different pool by /// prediction (alertsFuel + alert radius), which is a different pool by
/// design — that mismatch is what this fixes. /// design — that mismatch is what this fixes.
private var appCheapestStatus: DebugAppCheapest? { private var appCheapestStatus: DebugAppCheapest? {
guard let topID = topStationID, guard let cheapestID = cheapestStationID,
let station = stations.first(where: { $0.id == topID }), let station = stations.first(where: { $0.id == cheapestID }),
let location else { return nil } let location else { return nil }
return DebugAppCheapest( return DebugAppCheapest(
station: station, station: station,
@@ -418,7 +428,7 @@ struct ContentView: View {
/// ///
/// The baseline is chosen with the same journey guards as /// The baseline is chosen with the same journey guards as
/// `FuelSavingsEstimator.baselineStation` when configured; with the feature /// `FuelSavingsEstimator.baselineStation` when configured; with the feature
/// off, the ordinary Closest station still names that row. /// off, the ordinary closest station still names that row.
private func savingsScaffold() -> (baseline: FuelStation?, journeys: [String: FuelSavingsJourney]) { private func savingsScaffold() -> (baseline: FuelStation?, journeys: [String: FuelSavingsJourney]) {
guard let location else { return (nil, [:]) } guard let location else { return (nil, [:]) }
guard fuelSavingsSettings.isConfigured else { guard fuelSavingsSettings.isConfigured else {
@@ -814,11 +824,19 @@ struct ContentView: View {
station: station, station: station,
location: location, location: location,
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
priceStyle: priceDisplayStyle,
fuelSavings: savingsLookup(baseline: scaffold.baseline, fuelSavings: savingsLookup(baseline: scaffold.baseline,
journeys: scaffold.journeys)(station), journeys: scaffold.journeys)(station),
fuelSavingsAmount: sessionFuelSavingsAmount, fuelSavingsAmount: sessionFuelSavingsAmount,
savingsBaselineStationName: scaffold.baseline?.name, savingsBaselineStationName: scaffold.baseline?.name,
journeyDiagnostics: journeyDiagnostics(for: station, baseline: scaffold.baseline), journeyDiagnostics: journeyDiagnostics(for: station, baseline: scaffold.baseline),
// The same "cheapest in range" test this view gives its
// rows, so the sheet's Cheapest capsule matches the badge on
// the list it was opened from.
isCheapestInRange: station.id == cheapestStationID,
// The same "closest" test the rows get, from the ONE
// scaffold baseline the savings lookup also compares against.
isClosestInRange: station.id == scaffold.baseline?.id,
isFavourite: favouriteIDs.contains(station.id), isFavourite: favouriteIDs.contains(station.id),
onToggleFavourite: { toggleFavourite(station, fuel: selectedFuel) } onToggleFavourite: { toggleFavourite(station, fuel: selectedFuel) }
) )
@@ -843,6 +861,15 @@ struct ContentView: View {
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardRoadDistanceDebugUpdated)) { _ in .onReceive(NotificationCenter.default.publisher(for: .fuelBoardRoadDistanceDebugUpdated)) { _ in
roadDistanceDebugStatus = RoadDistanceService.currentDebugStatus() 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 .onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
let info = note.userInfo ?? [:] let info = note.userInfo ?? [:]
if let id = info["stationID"] as? String, if let id = info["stationID"] as? String,
@@ -1027,9 +1054,10 @@ struct ContentView: View {
sortMode = .worthTheDrive sortMode = .worthTheDrive
} }
// QA temp: `FB_DETAIL_DEBUG=1` (DEBUG builds only) forces // QA temp: `FB_DETAIL_DEBUG=1` (DEBUG builds only) forces
// "Tap station = More info". The stored default is open-Apple-Maps, // "Tap station = More info". The default is already the detail
// so on a simulator a tap bounces out to Maps with no way back and // sheet, so this only matters on a simulator whose store has
// the Worth the Drive detail section can't be checked at all. // "Directions" saved — it overrides the stored choice either way,
// which keeps the Worth the Drive detail section reachable.
if ProcessInfo.processInfo.environment["FB_DETAIL_DEBUG"] == "1" { if ProcessInfo.processInfo.environment["FB_DETAIL_DEBUG"] == "1" {
stationTapAction = .showDetails stationTapAction = .showDetails
} }
@@ -1123,7 +1151,6 @@ struct ContentView: View {
// or the choice wouldn't take effect until the next launch. // or the choice wouldn't take effect until the next launch.
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings() fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
sessionFuelSavingsAmount = fuelSavingsSettings.preferredAmount sessionFuelSavingsAmount = fuelSavingsSettings.preferredAmount
ensureBestValueAlertModeIsAvailable()
locationManager.startForegroundTracking() locationManager.startForegroundTracking()
installLocationUpdateHook() installLocationUpdateHook()
ensureBackgroundMotionTrackingIfNeeded() ensureBackgroundMotionTrackingIfNeeded()
@@ -1143,7 +1170,6 @@ struct ContentView: View {
#if DEBUG #if DEBUG
print("FB-SAVINGS settings changed: enabled=\(old.enabled)->\(new.enabled) configured=\(new.isConfigured)") print("FB-SAVINGS settings changed: enabled=\(old.enabled)->\(new.enabled) configured=\(new.isConfigured)")
#endif #endif
ensureBestValueAlertModeIsAvailable()
updateAlertMonitor() updateAlertMonitor()
} }
.onChange(of: sessionFuelSavingsAmount) { _, _ in .onChange(of: sessionFuelSavingsAmount) { _, _ in
@@ -1226,16 +1252,6 @@ struct ContentView: View {
FuelStore.saveAlertsFuel(newValue) FuelStore.saveAlertsFuel(newValue)
updateAlertMonitor(fuel: 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 .onChange(of: alertsBestValueThresholdGBP) { _, newValue in
FuelStore.saveAlertsBestValueThresholdGBP(newValue) FuelStore.saveAlertsBestValueThresholdGBP(newValue)
updateAlertMonitor() updateAlertMonitor()
@@ -1516,15 +1532,15 @@ struct ContentView: View {
/// within the compiler's type-check budget. /// within the compiler's type-check budget.
private var stationsTab: some View { private var stationsTab: some View {
// ONE scaffold (baseline + journeys) and ONE sort serve the list, its // ONE scaffold (baseline + journeys) and ONE sort serve the list, its
// count, the TOP badge and every savings label. Asking for // count, the Cheapest badge and every savings label. Asking for
// `nearestSavingsBaseline`, `displayedStations`, `sortedStations.count`, // `nearestSavingsBaseline`, `displayedStations`, `sortedStations.count`,
// `topStationID` and `savingsLookup` separately walked the full ~8k list // `cheapestStationID` and `savingsLookup` separately walked the full ~8k
// six times per render — seconds of main-thread work in Closest mode, // list six times per render — seconds of main-thread work in Closest mode,
// where the pool is every station in the UK. // where the pool is every station in the UK.
let scaffold = savingsScaffold() let scaffold = savingsScaffold()
let baselineStation = scaffold.baseline let baselineStation = scaffold.baseline
let displayed = displayedStations let displayed = displayedStations
let topID = baselinePrice.flatMap { topStationID(in: displayed, baselinePrice: $0) } let cheapestID = baselinePrice.flatMap { cheapestStationID(in: displayed, baselinePrice: $0) }
return StationsView( return StationsView(
stations: displayed, stations: displayed,
totalCount: displayed.count, totalCount: displayed.count,
@@ -1535,7 +1551,8 @@ struct ContentView: View {
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle, priceDisplayStyle: priceDisplayStyle,
baselinePrice: baselinePrice, baselinePrice: baselinePrice,
topStationID: topID, cheapestStationID: cheapestID,
closestStationID: baselineStation?.id,
location: location, location: location,
favouriteIDs: favouriteIDs, favouriteIDs: favouriteIDs,
stationTapAction: stationTapAction, stationTapAction: stationTapAction,
@@ -1578,9 +1595,6 @@ struct ContentView: View {
enabled: $alertsEnabled, enabled: $alertsEnabled,
radius: $alertsRadius, radius: $alertsRadius,
fuel: $alertsFuel, fuel: $alertsFuel,
alertMode: $alertsMode,
bestValueThresholdGBP: alertsBestValueThresholdGBP,
bestValueConfigured: fuelSavingsSettings.isConfigured,
favouriteDropEnabled: $favouriteDropAlertsEnabled, favouriteDropEnabled: $favouriteDropAlertsEnabled,
favouriteDropFuel: $favouriteDropAlertsFuel, favouriteDropFuel: $favouriteDropAlertsFuel,
followsSearch: $alertsFollowsSearch, followsSearch: $alertsFollowsSearch,
@@ -1792,8 +1806,11 @@ struct StationRow: View {
let priceDisplayStyle: PriceDisplayStyle let priceDisplayStyle: PriceDisplayStyle
let baselinePrice: Double? let baselinePrice: Double?
/// This row is the cheapest price in range — the caller's existing /// This row is the cheapest price in range — the caller's existing
/// `topStationID` test. Feeds the CHEAPEST badge. /// `cheapestStationID` test. Feeds the Cheapest badge.
let isTopResult: Bool let isCheapestResult: Bool
/// This row is the station savings are measured against — the caller's
/// `closestStationID` test. Feeds the Closest badge.
let isClosestResult: Bool
let isFavourite: Bool let isFavourite: Bool
var tapAction: StationTapAction = .openMap var tapAction: StationTapAction = .openMap
var fuelSavings: FuelSavingsAvailability? var fuelSavings: FuelSavingsAvailability?
@@ -1815,8 +1832,8 @@ struct StationRow: View {
/// styles. The mockup's divider sits at ~53% of its row; this is that ratio, /// 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 /// 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 hairline, the price axis and the star cannot move as the list scrolls.
/// The price column takes the remainder, and its savings capsule scales down /// The price column takes the remainder, and its listing verdict wraps
/// instead of widening the column and dragging the hairline with it. /// 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 /// 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` /// price line and hold ~28pt of the price column, so the wide form `£1.58⁷/L`
@@ -1826,11 +1843,6 @@ struct StationRow: View {
/// in pounds & pence, and names keep the same room in both styles. /// in pounds & pence, and names keep the same room in both styles.
private static let leftSectionShare: CGFloat = 0.52 private static let leftSectionShare: CGFloat = 0.52
/// The comparison label sits beside the capsule only where the row is wide
/// enough for both (regular width). On an iPhone the mockup's canvas is
/// wider than the whole screen, so the label drops under the capsule rather
/// than starving the station name.
@Environment(\.horizontalSizeClass) private var sizeClass
private var ragColor: Color { private var ragColor: Color {
guard let price = station.prices[fuel], let baselinePrice else { return .gray } guard let price = station.prices[fuel], let baselinePrice else { return .gray }
@@ -1858,12 +1870,13 @@ struct StationRow: View {
} }
/// The row's single recommendation badge, if any. BEST VALUE (worth the /// The row's single recommendation badge, if any. BEST VALUE (worth the
/// drive by the alert threshold) outranks CHEAPEST; NEAREST never gets one, /// drive by the alert threshold) outranks CHEAPEST, which outranks CLOSEST —
/// being context rather than a recommendation. /// see `StationRecommendationBadge.badge` for why proximity ranks last.
private var recommendationBadge: StationRecommendationBadge? { private var recommendationBadge: StationRecommendationBadge? {
StationRecommendationBadge.badge( StationRecommendationBadge.badge(
netSavingGBP: fuelSavings?.result?.netSavingGBP, netSavingGBP: fuelSavings?.result?.netSavingGBP,
isCheapest: isTopResult, isCheapest: isCheapestResult,
isClosest: isClosestResult,
bestValueThresholdGBP: bestValueThresholdGBP bestValueThresholdGBP: bestValueThresholdGBP
) )
} }
@@ -1941,14 +1954,18 @@ struct StationRow: View {
// Name, full address, distance // Name, full address, distance
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
// The name WRAPS instead of truncating: the row is tall // The name wraps, capped at THREE lines. The row is tall
// enough now, and a name cut to "Z A Service Stat…" cannot // enough for a two-line name, but an unbounded wrap let one
// be told apart from the station next door. Baseline // very long forecourt name set the height of the whole row —
// alignment keeps the notice symbols on the name's FIRST // the excessive row heights this refinement removes. Past
// line instead of floating beside a two-line name. // 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) { HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(station.name) Text(station.name)
.font(.headline) .font(.headline)
.lineLimit(3)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
ForEach(StationNotice.notices(for: station)) { notice in ForEach(StationNotice.notices(for: station)) { notice in
@@ -2019,17 +2036,20 @@ struct StationRow: View {
if let badge = recommendationBadge { if let badge = recommendationBadge {
badgeCapsule(badge) badgeCapsule(badge)
} }
// Price line: the status dot and the price, leading-aligned on // The price and its verdict capsule are ONE vertically grouped
// the column's one axis. The star no longer ends this line — it // element: 5pt binds them as "this price, this outcome", while
// sits under the station logo — so this whole column width now // the badge keeps the column's full 12pt above so the three
// belongs to the price, which is what lets the wide pounds & // lines do not read as equals.
// pence form (£1.58⁷/L) render at full size rather than being VStack(alignment: .leading, spacing: 5) {
// scaled down or truncated. // 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] { if let price = station.prices[fuel] {
HStack(spacing: 5) { HStack(spacing: 5) {
Circle() PriceRatingDot(color: ragColor)
.fill(ragColor)
.frame(width: 8, height: 8)
FuelStore.priceTextAttributed(price, style: priceDisplayStyle, FuelStore.priceTextAttributed(price, style: priceDisplayStyle,
size: priceSize, weight: .bold) size: priceSize, weight: .bold)
.lineLimit(1) .lineLimit(1)
@@ -2040,10 +2060,15 @@ struct StationRow: View {
.accessibilityElement(children: .ignore) .accessibilityElement(children: .ignore)
.accessibilityLabel(priceSpokenLabel(price)) .accessibilityLabel(priceSpokenLabel(price))
} }
if let result = fuelSavings?.result { // The closest station wears the Closest badge above its price
// and gets no verdict capsule: the capsule's only word for that
// state was "Closest", so printing it here as well would say
// the same thing twice, three lines apart.
if let result = fuelSavings?.result, !isClosestResult {
savingsCapsule(result) savingsCapsule(result)
} }
} }
}
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
// The spec's ~20-24pt of air between the divider and the price // The spec's ~20-24pt of air between the divider and the price
// column: the row's own 12pt spacing plus this 10pt. // column: the row's own 12pt spacing plus this 10pt.
@@ -2062,67 +2087,41 @@ struct StationRow: View {
Button { Button {
onToggleFavourite() onToggleFavourite()
} label: { } label: {
Image(systemName: isFavourite ? "star.fill" : "star") FavouriteStarGlyph(isFavourite: isFavourite)
.foregroundStyle(isFavourite ? .yellow : .secondary)
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
} }
/// The savings capsule plus the comparison it is measured against. The /// The listing's verdict capsule — ONE component for every status, so the
/// capsule itself is the shared verdict view, so a row, the detail sheet and /// baseline's "Closest" pill carries the same fill, text and shape rules as
/// the compact summary cannot drift apart. The comparison label sits beside /// Saves, Costs and Break even. The detail sheet keeps `FuelSavingsCapsule` to
/// the capsule only where there is width for both. /// itself, unchanged.
@ViewBuilder
private func savingsCapsule(_ result: FuelSavingsResult) -> some View { private func savingsCapsule(_ result: FuelSavingsResult) -> some View {
let comparison = Text(FuelSavingsFormat.comparisonLabel(status: result.status)) ListingFuelSavingsCapsule(result: result)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
// Same narrow column as the capsule above it, so the same floor:
// scaled copy, not a trimmed "vs closest st…".
.minimumScaleFactor(0.7)
if sizeClass == .regular {
HStack(spacing: 8) {
FuelSavingsCapsule(result: result)
comparison
}
} else {
VStack(alignment: .leading, spacing: 2) {
FuelSavingsCapsule(result: result)
comparison
}
}
} }
/// One recommendation badge, above the price it annotates. Tinted rather /// One recommendation badge, above the price it annotates. Badges stay
/// than the mockup's solid fill, like every other badge and capsule in the /// TINTED with coloured text — unlike the verdict capsule beneath, which is
/// app: coloured text keeps its contrast in both appearances, at every /// filled: a badge annotates the price rather than reporting an outcome, so it
/// Dynamic Type size, without introducing a colour outside the palette. /// stays quieter than the pill below it, and tinted text keeps its contrast in
/// both appearances at every Dynamic Type size.
private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View { private func badgeCapsule(_ badge: StationRecommendationBadge) -> some View {
Text(badge.title) StationLabelBadge(kind: badge.labelKind)
.font(.caption2.bold())
.foregroundStyle(badge.tint)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Capsule().fill(badge.tint.opacity(0.15)))
.lineLimit(1)
.fixedSize()
} }
} }
/// Badge copy and colour for `StationRecommendationBadge` — view concerns, so /// Badge copy and colour for `StationRecommendationBadge` — view concerns, so
/// they stay out of the shared model the tests cover. /// they stay out of the shared model the tests cover.
private extension StationRecommendationBadge { private extension StationRecommendationBadge {
var title: String { /// The shared label kind, so a row's badge and the Key's legend for the same
/// meaning can never drift apart.
var labelKind: StationLabelKind {
switch self { switch self {
case .bestValue: case .bestValue: return .bestValue
return String(localized: "BEST VALUE", comment: "Station badge: saving clears the Best Value threshold") case .cheapest: return .cheapest
case .cheapest: case .closest: return .closest
return String(localized: "CHEAPEST", comment: "Station badge: cheapest price in range")
} }
} }
var tint: Color { self == .bestValue ? .green : .blue }
} }
// MARK: - Location manager // MARK: - Location manager
+82 -4
View File
@@ -48,6 +48,14 @@ struct FavouritesView: View {
/// Trends sheet (price history chart) presentation state. /// Trends sheet (price history chart) presentation state.
@State private var showTrends = false @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). /// Station picked for the More-info sheet (tap action = showDetails).
@State private var detailStation: FuelStation? @State private var detailStation: FuelStation?
@@ -69,6 +77,35 @@ struct FavouritesView: View {
} }
private var cheapestPrice: Double? { cheapest?.prices[activeFuel] } private var cheapestPrice: Double? { cheapest?.prices[activeFuel] }
/// Nearest favourited station for the active fuel — this tab's Closest.
///
/// Measured on the same one-way distance the rows print, so the badge cannot
/// point at a station the list shows as further away than its neighbour. Only
/// favourites with a price for the active fuel are eligible, matching the
/// radius tab, where the closest station is by definition the savings
/// baseline; an unpriced favourite appears last and wears no label. Ties keep
/// the user's own order, which is what `ordered` already encodes.
private var nearest: FuelStation? {
guard let location else { return nil }
var best: (station: FuelStation, km: Double)?
for station in ordered where station.prices[activeFuel] != nil {
let km = FuelStore.stationDistanceDisplay(
station: station, userLat: location.lat, userLng: location.lng
).km
if best == nil || km < best!.km { best = (station, km) }
}
return best?.station
}
/// 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. /// IDs favourited for the active fuel — the star state on each row.
private var activeFuelFavouriteIDs: Set<String> { private var activeFuelFavouriteIDs: Set<String> {
Set(favourites.filter { $0.fuel == activeFuel }.map(\.station.id)) Set(favourites.filter { $0.fuel == activeFuel }.map(\.station.id))
@@ -145,8 +182,15 @@ struct FavouritesView: View {
if fuelSavingsSettings.isConfigured { if fuelSavingsSettings.isConfigured {
Section { Section {
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount) FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
} footer: { }
Text(String(localized: "Savings below are estimated against the closest station by journey distance — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation")) }
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)
} }
} }
@@ -159,7 +203,8 @@ struct FavouritesView: View {
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle, priceDisplayStyle: priceDisplayStyle,
baselinePrice: cheapestPrice, baselinePrice: cheapestPrice,
isTopResult: station.id == cheapest?.id, isCheapestResult: station.id == cheapest?.id,
isClosestResult: station.id == nearest?.id,
isFavourite: activeFuelFavouriteIDs.contains(station.id), isFavourite: activeFuelFavouriteIDs.contains(station.id),
tapAction: stationTapAction, tapAction: stationTapAction,
fuelSavings: savingsForStation(station), fuelSavings: savingsForStation(station),
@@ -171,8 +216,13 @@ struct FavouritesView: View {
} header: { } header: {
Text(String(format: String(localized: "%@ favourites", comment: "Favourites section title for fuel type"), activeFuel.displayName)) Text(String(format: String(localized: "%@ favourites", comment: "Favourites section title for fuel type"), activeFuel.displayName))
} footer: { } 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")) 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 { if let cheapest = cheapest {
Section { Section {
@@ -185,8 +235,8 @@ struct FavouritesView: View {
} }
.navigationTitle(String(localized: "Favourites", comment: "Favourites tab title")) .navigationTitle(String(localized: "Favourites", comment: "Favourites tab title"))
.toolbar { .toolbar {
if !favourites.isEmpty {
ToolbarItemGroup(placement: .topBarTrailing) { ToolbarItemGroup(placement: .topBarTrailing) {
if !favourites.isEmpty {
Button { Button {
showTrends = true showTrends = true
} label: { } label: {
@@ -195,7 +245,23 @@ struct FavouritesView: View {
} }
EditButton() 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(
showBestValueInfo: $showBestValueInfo,
showImportantInformation: $showImportantInformation
)
} }
.sheet(isPresented: $showTrends) { .sheet(isPresented: $showTrends) {
TrendsView( TrendsView(
@@ -211,16 +277,28 @@ struct FavouritesView: View {
station: station, station: station,
location: location, location: location,
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
priceStyle: priceDisplayStyle,
selectedFuel: activeFuel, selectedFuel: activeFuel,
fuelSavings: savingsForStation(station), fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount, fuelSavingsAmount: fuelSavingsAmount,
journeyDiagnostics: journeyDiagnosticsForStation(station), journeyDiagnostics: journeyDiagnosticsForStation(station),
// This tab's cheapest favourite — the same flag its rows get,
// and the same label they wear.
isCheapestInRange: station.id == cheapest?.id,
// This tab's nearest favourite — the same flag its rows get.
isClosestInRange: station.id == nearest?.id,
// Every station listed here IS a favourite for the tab's // Every station listed here IS a favourite for the tab's
// fuel, so the star starts filled; tapping it unstars. // fuel, so the star starts filled; tapping it unstars.
isFavourite: true, isFavourite: true,
onToggleFavourite: { onToggleFavourite(station, activeFuel) } 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 { .onAppear {
#if DEBUG #if DEBUG
// QA hook: launch with `-showTrends` to open the sheet // 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)
}
}
+159 -235
View File
@@ -3,7 +3,7 @@ import SwiftUI
struct FuelSavingsAdjustmentView: View { struct FuelSavingsAdjustmentView: View {
@Binding var amount: FuelSavingsFillAmount @Binding var amount: FuelSavingsFillAmount
@State private var unitChangeNotice: String? @State private var showingComparisonHelp = false
private var range: ClosedRange<Double> { private var range: ClosedRange<Double> {
FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit) FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit)
@@ -26,8 +26,33 @@ struct FuelSavingsAdjustmentView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("How much are you filling up?") HStack {
Text("How much fuel?")
.font(.subheadline.weight(.semibold)) .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) { Picker("Fill-up unit", selection: unitBinding) {
ForEach(FuelSavingsFillUnit.allCases) { unit in ForEach(FuelSavingsFillUnit.allCases) { unit in
Text(unit.displayName).tag(unit) Text(unit.displayName).tag(unit)
@@ -42,17 +67,9 @@ struct FuelSavingsAdjustmentView: View {
.monospacedDigit() .monospacedDigit()
.frame(width: 72, alignment: .trailing) .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) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text("£ compares the litres your budget buys at each station; Litres compares one fixed quantity.")
.font(.caption2)
.foregroundStyle(.secondary)
if let unitChangeNotice {
Text(unitChangeNotice)
.font(.caption2)
.foregroundStyle(.orange)
}
} }
} }
@@ -61,10 +78,9 @@ struct FuelSavingsAdjustmentView: View {
get: { amount.unit }, get: { amount.unit },
set: { newUnit in set: { newUnit in
guard newUnit != amount.unit else { return } guard newUnit != amount.unit else { return }
// No one litre figure equals a budget at every candidate price. // No one litre figure equals a budget at every candidate price,
// A unit change starts a new, explicitly labelled comparison. // so switching modes starts from that mode's configured default.
amount = FuelSavingsCalculator.newComparisonAmount(afterSwitching: amount, to: newUnit) amount = FuelSavingsCalculator.newComparisonAmount(afterSwitching: amount, to: newUnit)
unitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up."
} }
) )
} }
@@ -77,206 +93,8 @@ struct FuelSavingsAdjustmentView: View {
} }
} }
struct FuelSavingsSummaryView: View { /// The colour-coded monetary verdict capsule used by the detail sheet and other
let availability: FuelSavingsAvailability? /// full financial summaries. Listing rows use `ListingFuelSavingsCapsule`.
let amount: FuelSavingsFillAmount
var baselineStationName: String? = nil
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
private func comparisonLabel(for result: FuelSavingsResult) -> String {
FuelSavingsFormat.comparisonLabel(status: result.status)
}
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) {
// The capsule gets the WHOLE line and the comparison caption
// sits beneath it. Side by side, the caption's layoutPriority
// squeezed the verdict until it truncated ("Approx. £2.75
// better off after tra…"), and in this sort mode the verdict
// is the point of the row.
capsule(for: result, compact: true)
.accessibilityLabel(accessibilityHeadline(for: result))
Text(comparisonLabel(for: result))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.85)
.accessibilityLabel("Comparison: \(comparisonLabel(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) {
// Same shape as the listing rows: the capsule takes the line
// and the comparison caption sits directly beneath it, so the
// verdict never competes for width with its own caption.
VStack(alignment: .leading, spacing: 4) {
capsule(for: result, compact: false)
.accessibilityLabel(accessibilityHeadline(for: result))
Text(comparisonLabel(for: result))
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.85)
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
}
LabeledContent("Your fill-up", value: amountText)
LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres))
.monospacedDigit()
if amount.unit == .pounds {
Text("You pay the selected budget here. The saving compares the same litres at your closest station; litres differ between stations, and this is not a refund.")
.font(.caption2)
.foregroundStyle(.secondary)
}
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 by journey distance is the baseline — every other saving is measured against it.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text(baselineStationName.map { "Compared with \($0)." } ?? "Compared with your closest 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
/// 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 lives in `FuelSavingsCapsule` — ONE
/// implementation for the listing rows, the detail sheet and this compact
/// summary, so the verdict reads identically everywhere; the sheet just
/// gets the larger font and padding.
private func capsule(for result: FuelSavingsResult, compact: Bool) -> some View {
FuelSavingsCapsule(result: result, compact: compact)
}
/// Verdict colour lives in `FuelSavingsCapsule.color(for:)`.
private func capsuleColor(for status: FuelSavingsStatus) -> Color {
FuelSavingsCapsule.color(for: status)
}
private func symbol(for status: FuelSavingsStatus) -> String {
FuelSavingsCapsule.symbol(for: status)
}
}
/// The colour-coded verdict capsule shared by the station listing rows, the
/// detail sheet and the compact summary.
/// ///
/// An HStack with an explicit spacing, NOT a Label: given the width to spare, /// 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 /// a Label lays its icon and title out across the row and leaves a visible gap
@@ -287,44 +105,150 @@ struct FuelSavingsCapsule: View {
let result: FuelSavingsResult let result: FuelSavingsResult
var compact: Bool = true var compact: Bool = true
/// One line of `.headline` / `.caption`, scaled with Dynamic Type, so the
/// shared shape rule can tell one drawn line from two.
@ScaledMetric(relativeTo: .headline) private var headlineLineHeight: CGFloat = 22
@ScaledMetric(relativeTo: .caption) private var captionLineHeight: CGFloat = 14
private var tone: CapsuleTone { Self.tone(for: result.status) }
var body: some View { var body: some View {
AdaptiveCapsule(
fill: tone.background,
lineHeight: compact ? captionLineHeight : headlineLineHeight,
horizontalPadding: compact ? 8 : 12,
verticalPadding: compact ? 4 : 7
) {
HStack(spacing: compact ? 3 : 5) { HStack(spacing: compact ? 3 : 5) {
Image(systemName: Self.symbol(for: result.status)) Image(systemName: Self.symbol(for: result.status))
.accessibilityHidden(true) .accessibilityHidden(true)
Text(result.capsuleHeadline()) Text(compact ? result.capsuleHeadline() : result.detailCapsuleHeadline())
.lineLimit(1) // The detail sheet's capsule sits beside a headline and has the
// Measured on a 393pt screen: the capsule is handed ~101pt, of // width for its wording; the listing's is narrow and scales.
// which the icon, its spacing and the 8pt padding either side .lineLimit(compact ? 1 : nil)
// take 32pt, leaving ~69pt for the text. "Closest station" needs .minimumScaleFactor(compact ? 0.7 : 1)
// ~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) .font(compact ? .caption.weight(.semibold) : .headline)
.foregroundStyle(Self.color(for: result.status)) .foregroundStyle(tone.foreground)
.padding(.horizontal, compact ? 8 : 12) }
.padding(.vertical, compact ? 4 : 7)
.background(Capsule().fill(Self.color(for: result.status).opacity(0.15)))
} }
/// Verdict colour: green save, red worse off, grey break even, blue /// Verdict tone: green save, red worse off, grey break even, neutral-grey
/// baseline — the same green/amber/red family the price dot already uses. /// baseline — the same meanings the price dot's RAG family carries.
static func color(for status: FuelSavingsStatus) -> Color { static func tone(for status: FuelSavingsStatus) -> CapsuleTone {
switch status { switch status {
case .worthTheDrive: return .green case .worthTheDrive: return .saves
case .notWorthTheDrive: return .red case .notWorthTheDrive: return .costs
case .breakEven: return .secondary case .breakEven: return .breakEven
case .baseline: return .blue case .baseline: return .closest
} }
} }
/// Foreground-only form, for a `Text` that is tinted rather than filled (the
/// detail sheet's Total row). Keeps one source for the verdict colour.
static func color(for status: FuelSavingsStatus) -> Color {
tone(for: status).foreground
}
static func symbol(for status: FuelSavingsStatus) -> String { static func symbol(for status: FuelSavingsStatus) -> String {
switch status { switch status {
case .worthTheDrive: return "checkmark.circle.fill" case .worthTheDrive: return "checkmark.circle.fill"
case .breakEven: return "equal.circle.fill" case .breakEven: return "equal.circle.fill"
case .notWorthTheDrive: return "xmark.circle.fill" case .notWorthTheDrive: return "xmark.circle.fill"
case .baseline: return "location.circle.fill" // The ONE closest glyph, shared with the Key legend and the station
// badges (`StationLabelKind.closest`). The other three verdicts are
// circular because they report an outcome; closest reports a position,
// and the plain location pointer is what the legend teaches for it.
case .baseline: return StationLabelKind.closest.symbol
}
}
}
/// 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
/// One caption line's height, scaled with Dynamic Type, for the shared shape
/// rule. The drawn height is divided by this, so the shape follows the lines
/// actually on screen rather than the number the verdict declares.
@ScaledMetric(relativeTo: .caption) private var captionLineHeight: CGFloat = 14
private var verdict: FuelSavingsResult.ListingVerdict {
result.listingVerdict(showsAmount: showsAmount)
}
private var tone: CapsuleTone { FuelSavingsCapsule.tone(for: result.status) }
var body: some View {
AdaptiveCapsule(
fill: tone.background,
lineHeight: captionLineHeight,
declaredLineCount: verdict.lines.count,
horizontalPadding: 8,
verticalPadding: 4
) {
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)
}
// Tinted foreground on a tinted fill. These capsules carry a figure the
// eye is asked to read at a glance, so they are filled rather than
// tinted-text-only; every badge above them stays the quieter tinted form.
.font(.caption.weight(.semibold))
.foregroundStyle(tone.foreground)
}
// 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)
.accessibilityElement(children: .combine)
.accessibilityLabel(verdict.spokenText)
}
/// Saves and Costs only — the two verdicts that carry a figure. 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 symbol: String {
switch result.status {
case .worthTheDrive: return "checkmark.circle"
case .notWorthTheDrive: return "arrow.up.circle"
case .breakEven: return "equal.circle"
// Same closest glyph as the detail verdict and the Key — this capsule is
// the listing's copy of the same state, not a third icon for it.
case .baseline: return StationLabelKind.closest.symbol
} }
} }
} }
+90 -4
View File
@@ -1,6 +1,6 @@
import SwiftUI import SwiftUI
/// The "How Best Value works" explanation as its own sheet. /// The "How does FuelBoard calculate value?" explanation as its own sheet.
/// ///
/// It used to sit inline in the Key sheet as a `Section`; it is one shared view /// It used to sit inline in the Key sheet as a `Section`; it is one shared view
/// now so the Key sheet and the More info sheet can both open it, and the copy is /// now so the Key sheet and the More info sheet can both open it, and the copy is
@@ -16,6 +16,50 @@ struct HowBestValueSheet: View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
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("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.") 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 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("With Litres, every station is compared for the fixed quantity you selected.")
Text("Results are marked “Approx.” until road distances are available.") Text("Results are marked “Approx.” until road distances are available.")
@@ -25,7 +69,11 @@ struct HowBestValueSheet: View {
.padding(.vertical, 4) .padding(.vertical, 4)
} }
} }
.navigationTitle("How Best Value works") .navigationTitle(KeySheetContent.helpSheetTitle)
// `.inline`, the mode this sheet has always used. The sheet's title is a
// short noun phrase and the ROW that opens it carries the full question
// — a nav title is clamped to the bar and even `.large` truncated the
// full question to "How does FuelBoard…" rather than wrapping it.
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
@@ -33,8 +81,46 @@ struct HowBestValueSheet: View {
} }
} }
} }
.presentationDetents([.fraction(0.42)]) .presentationDetents([.fraction(0.72)])
.presentationBackground(Color(UIColor.systemGroupedBackground)) .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)
}
} }
} }
@@ -48,7 +134,7 @@ struct HowBestValueRow: View {
var body: some View { var body: some View {
Button(action: action) { Button(action: action) {
HStack(spacing: 8) { HStack(spacing: 8) {
Text("How Best Value works") Text(KeySheetContent.helpTitle)
Spacer(minLength: 8) Spacer(minLength: 8)
Image(systemName: "chevron.forward") Image(systemName: "chevron.forward")
.font(.footnote.weight(.semibold)) .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
@@ -132,7 +132,7 @@ enum RoadDistanceService {
// visible to show for it: no pass, no requests, no widget reloads. // visible to show for it: no pass, no requests, no widget reloads.
guard FuelStore.accurateDistancesEnabled else { guard FuelStore.accurateDistancesEnabled else {
await updateDebug(baseDebugStatus(state: .gated, await updateDebug(baseDebugStatus(state: .gated,
reason: "Accurate distances off", reason: "Driving distances off",
stations: stations, stations: stations,
lat: lat, lat: lat,
lng: lng, lng: lng,
+123 -53
View File
@@ -70,11 +70,21 @@ struct SettingsView: View {
/// Hidden developer flag — the Debug section only appears when on. Toggled /// Hidden developer flag — the Debug section only appears when on. Toggled
/// by tapping the About → Version row five times (NOT a user-facing switch). /// by tapping the About → Version row five times (NOT a user-facing switch).
@State private var debugMode: Bool = FuelStore.loadDebugMode() @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. /// the shared store, which is what every display surface actually consults.
@State private var accurateDistances: Bool = FuelStore.accurateDistancesEnabled @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 versionTapCount = 0
@State private var lastVersionTap = Date.distantPast @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 /// Widget diagnostics: the extension's last makeEntry beacon PER intent
/// type (keychain) + the installed-widget inventory from WidgetCenter. /// type (keychain) + the installed-widget inventory from WidgetCenter.
@State private var smallWidgetDiag: String? @State private var smallWidgetDiag: String?
@@ -85,7 +95,6 @@ struct SettingsView: View {
/// Return key, so the keyboard's Done button clears this to dismiss AND /// Return key, so the keyboard's Done button clears this to dismiss AND
/// commit the typed value. Both Fuel Savings controls are sliders now, so /// commit the typed value. Both Fuel Savings controls are sliders now, so
/// nothing sets it — it stays for any numeric field this screen takes again. /// nothing sets it — it stays for any numeric field this screen takes again.
@State private var settingsUnitChangeNotice: String?
@FocusState private var focusedSavingsField: SavingsField? @FocusState private var focusedSavingsField: SavingsField?
private enum SavingsField: Hashable { private enum SavingsField: Hashable {
@@ -117,6 +126,9 @@ struct SettingsView: View {
List { List {
Section { Section {
Toggle("Best Value", isOn: fuelSavingsEnabledBinding) 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 // The feature's own controls follow its toggle: switched
// off they grey out but stay on screen (same rule as the // off they grey out but stay on screen (same rule as the
// distance picker in Closest mode) and keep their values, so // distance picker in Closest mode) and keep their values, so
@@ -132,11 +144,20 @@ struct SettingsView: View {
.monospacedDigit() .monospacedDigit()
.foregroundStyle(.secondary) .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, in: FuelSavingsCalculator.mpgSliderRange,
step: FuelSavingsCalculator.mpgSliderStep) step: FuelSavingsCalculator.mpgSliderStep) { editing in
if !editing { commitMPG() }
}
.accessibilityLabel("Average fuel economy") .accessibilityLabel("Average fuel economy")
.accessibilityValue(mpgText) .accessibilityValue(mpgText)
Text("How many miles your car travels per gallon.")
.font(.footnote)
.foregroundStyle(.secondary)
Picker("Fill-up unit", selection: fuelSavingsUnitBinding) { Picker("Fill-up unit", selection: fuelSavingsUnitBinding) {
ForEach(FuelSavingsFillUnit.allCases) { unit in ForEach(FuelSavingsFillUnit.allCases) { unit in
Text(unit.displayName).tag(unit) Text(unit.displayName).tag(unit)
@@ -144,17 +165,22 @@ struct SettingsView: View {
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
HStack { HStack {
Text("Typical fill-up") Text("Fill-up amount")
Spacer() Spacer()
Text(fillUpText) Text(fillUpText)
.monospacedDigit() .monospacedDigit()
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Slider(value: fuelSavingsAmountBinding, Slider(value: fillUpSliderValue,
in: FuelSavingsCalculator.fillAmountSliderRange(for: fuelSavingsSettings.preferredAmount.unit), in: FuelSavingsCalculator.fillAmountSliderRange(for: fuelSavingsSettings.preferredAmount.unit),
step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit)) step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit)) { editing in
.accessibilityLabel("Typical fill-up") if !editing { commitFillUp() }
}
.accessibilityLabel("Fill-up amount")
.accessibilityValue(fillUpText) .accessibilityValue(fillUpText)
Text(fillUpAmountDescription)
.font(.footnote)
.foregroundStyle(.secondary)
Picker("Minimum saving", selection: $bestValueThresholdGBP) { Picker("Minimum saving", selection: $bestValueThresholdGBP) {
ForEach(FuelStore.bestValueAlertThresholdOptionsGBP, id: \.self) { value in ForEach(FuelStore.bestValueAlertThresholdOptionsGBP, id: \.self) { value in
Text(FuelSavingsFormat.money(value)).tag(value) Text(FuelSavingsFormat.money(value)).tag(value)
@@ -166,25 +192,20 @@ struct SettingsView: View {
// needs no handler of its own. The store snaps to the // needs no handler of its own. The store snaps to the
// nearest option, so the picker and the stored value // nearest option, so the picker and the stored value
// cannot disagree. // cannot disagree.
Text("A saving worth driving for: the net saving left after the extra travel cost. Stations above it are labelled BEST VALUE in the station list, and Best Value alerts use the same figure.") Text("The smallest saving you'd consider worth the extra drive.")
.font(.caption) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if let settingsUnitChangeNotice {
Text(settingsUnitChangeNotice)
.font(.caption2)
.foregroundStyle(.orange)
}
if !fuelSavingsSettings.isConfigured && fuelSavingsSettings.enabled { if !fuelSavingsSettings.isConfigured && fuelSavingsSettings.enabled {
Text("Set a realistic UK MPG and fill-up amount before Best Value appears in sorting.") Text("Set a realistic UK MPG and fill-up amount before Best Value appears in sorting.")
.font(.caption) .font(.footnote)
.foregroundStyle(.orange) .foregroundStyle(.orange)
} }
} }
.disabled(!fuelSavingsSettings.enabled) .fuelBoardDisabledState(isEnabled: fuelSavingsSettings.enabled)
} header: { } header: {
Text("Fuel Savings") Text("Fuel Savings")
} footer: { } 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 { Section {
@@ -199,6 +220,9 @@ struct SettingsView: View {
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
WatchSyncManager.shared.pushSnapshot() WatchSyncManager.shared.pushSnapshot()
} }
Text("Choose how distances are displayed throughout FuelBoard.")
.font(.footnote)
.foregroundStyle(.secondary)
Picker("Price display", selection: $priceDisplayStyle) { Picker("Price display", selection: $priceDisplayStyle) {
ForEach(PriceDisplayStyle.allCases) { style in ForEach(PriceDisplayStyle.allCases) { style in
Text(style.displayName).tag(style) Text(style.displayName).tag(style)
@@ -210,24 +234,26 @@ struct SettingsView: View {
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
WatchSyncManager.shared.pushSnapshot() WatchSyncManager.shared.pushSnapshot()
} }
Text("Show prices as 129.9p or £1.299 per litre.")
.font(.footnote)
.foregroundStyle(.secondary)
} header: { } header: {
Text("Units") 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 { Section {
Toggle("Accurate distances", isOn: $accurateDistances) Toggle("Driving distances", isOn: $accurateDistances)
.onChange(of: accurateDistances) { _, newValue in .onChange(of: accurateDistances) { _, newValue in
FuelStore.setAccurateDistancesEnabled(newValue) FuelStore.setAccurateDistancesEnabled(newValue)
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
NotificationCenter.default.post(name: .fuelBoardAccurateDistancesChanged, NotificationCenter.default.post(name: .fuelBoardAccurateDistancesChanged,
object: nil) 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: { } header: {
Text("Distances") 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 { Section {
@@ -240,10 +266,11 @@ struct SettingsView: View {
.onChange(of: stationTapAction) { _, newValue in .onChange(of: stationTapAction) { _, newValue in
FuelStore.saveStationTapAction(newValue) FuelStore.saveStationTapAction(newValue)
} }
Text("Choose what happens when you tap a station: get directions or view its details.")
.font(.footnote)
.foregroundStyle(.secondary)
} header: { } header: {
Text("Tap action") Text("Tap action")
} footer: {
Text("Tapping a station either opens directions straight away or shows its details first.")
} }
Section { Section {
@@ -260,14 +287,12 @@ struct SettingsView: View {
} }
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(dataRefreshMode.summary) 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) .foregroundStyle(.secondary)
} }
.font(.footnote) .font(.footnote)
} header: { } header: {
Text("Data checking") 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 { Section {
@@ -276,8 +301,9 @@ struct SettingsView: View {
} label: { } label: {
Label("Show introduction", systemImage: "arrow.clockwise.circle") Label("Show introduction", systemImage: "arrow.clockwise.circle")
} }
} footer: { Text("View the welcome screens and set up permissions again.")
Text("Replay the welcome screen, including the location and notification permission prompts.") .font(.footnote)
.foregroundStyle(.secondary)
} }
if debugMode { if debugMode {
@@ -454,7 +480,7 @@ struct SettingsView: View {
} header: { } header: {
Text("Road distance fill") Text("Road distance fill")
} footer: { } 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 { Section {
@@ -559,7 +585,7 @@ struct SettingsView: View {
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(tipStore.purchaseInProgress) .fuelBoardDisabledState(isEnabled: !tipStore.purchaseInProgress)
} }
} }
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
@@ -584,11 +610,16 @@ struct SettingsView: View {
Text("About") Text("About")
} }
Section {
ImportantInformationRow { showImportantInformation = true }
}
} }
// Numeric entry must be escapable: `.decimalPad` has no Return key. // Numeric entry must be escapable: `.decimalPad` has no Return key.
.keyboardDoneButton { focusedSavingsField = nil } .keyboardDoneButton { focusedSavingsField = nil }
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.navigationTitle("Settings") .navigationTitle("Settings")
.importantInformationSheet(isPresented: $showImportantInformation)
.onAppear { .onAppear {
Task { await tipStore.load() } Task { await tipStore.load() }
} }
@@ -710,27 +741,56 @@ struct SettingsView: View {
private var fuelSavingsEnabledBinding: Binding<Bool> { private var fuelSavingsEnabledBinding: Binding<Bool> {
Binding( Binding(
get: { fuelSavingsSettings.enabled }, 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( Binding(
get: { fuelSavingsSettings.mpg }, get: { mpgDraft ?? fuelSavingsSettings.mpg },
set: { fuelSavingsSettings.mpg = min(max($0, 0), FuelSavingsCalculator.maximumMPG); persistFuelSavingsSettings() } set: { mpgDraft = $0 }
) )
} }
private var fuelSavingsAmountBinding: Binding<Double> { private var fillUpSliderValue: Binding<Double> {
Binding( Binding(
get: { fuelSavingsSettings.preferredAmount.value }, get: { fillUpDraft ?? fuelSavingsSettings.preferredAmount.value },
set: { set: { fillUpDraft = $0 }
fuelSavingsSettings.preferredAmount.value = min(max($0, 0), FuelSavingsCalculator.maximumFillAmount(for: fuelSavingsSettings.preferredAmount.unit))
persistFuelSavingsSettings()
}
) )
} }
/// 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> { private var fuelSavingsUnitBinding: Binding<FuelSavingsFillUnit> {
Binding( Binding(
get: { fuelSavingsSettings.preferredAmount.unit }, get: { fuelSavingsSettings.preferredAmount.unit },
@@ -738,31 +798,41 @@ struct SettingsView: View {
guard newUnit != fuelSavingsSettings.preferredAmount.unit else { return } guard newUnit != fuelSavingsSettings.preferredAmount.unit else { return }
fuelSavingsSettings.preferredAmount = FuelSavingsCalculator.newComparisonAmount( fuelSavingsSettings.preferredAmount = FuelSavingsCalculator.newComparisonAmount(
afterSwitching: fuelSavingsSettings.preferredAmount, to: newUnit) afterSwitching: fuelSavingsSettings.preferredAmount, to: newUnit)
settingsUnitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up." // The unit and the amount move together, so both keys are written.
persistFuelSavingsSettings() 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 /// "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 { 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) 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 { private var fillUpText: String {
let value = fillUpDraft ?? fuelSavingsSettings.preferredAmount.value
switch fuelSavingsSettings.preferredAmount.unit { switch fuelSavingsSettings.preferredAmount.unit {
case .pounds: return String(format: "£%.0f", fuelSavingsSettings.preferredAmount.value) case .pounds: return String(format: "£%.0f", value)
case .litres: return String(format: "%.0f L", fuelSavingsSettings.preferredAmount.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" /// "API" when the relay is serving the official Fuel Finder API, "CSV"
/// when it fell back to the public mirror, "—" when unknown/never fetched. /// when it fell back to the public mirror, "—" when unknown/never fetched.
private var connectionText: String { private var connectionText: String {
+288
View File
@@ -0,0 +1,288 @@
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] {
FuelStore.priceTextAttributed(price, style: priceStyle, size: priceSize, weight: .bold)
} else {
Text("—")
.font(.title2.weight(.bold))
.monospacedDigit()
}
}
/// One-way distance, with its confidence marker, from the shared display.
private var distanceText: String {
distance?.label(unit: distanceUnit) ?? "—"
}
/// Out-and-back distance, same source and same marker rule.
private var returnText: String {
distance?.returnLabel(unit: distanceUnit) ?? ""
}
}
/// A one-pixel vertical hairline between the facts row's two columns, drawn in
/// the List's own separator colour so it matches every other line on the sheet.
/// Decorative: VoiceOver reads each column as one phrase instead.
private struct StationFactsDivider: View {
var body: some View {
Rectangle()
.fill(Color(.separator))
.frame(width: 0.5)
.frame(maxHeight: .infinity)
.accessibilityHidden(true)
}
}
/// The More info sheet's Best Value card: the verdict, then the figures it is
/// built from.
///
/// Nothing is recomputed here — every number comes from the shared
/// `FuelSavingsResult`, the verdict capsule is the same `FuelSavingsCapsule` the
/// listing rows use, and the total is the shared `headline()`. Wording and
/// colour therefore cannot drift between the sheet and the list.
struct BestValueDetailCard: View {
let availability: FuelSavingsAvailability
let amount: FuelSavingsFillAmount
let baselineStationName: String?
let distanceUnit: DistanceUnit
var body: some View {
switch availability {
case .available(let result):
availableBody(result)
case .unavailable(let reason):
Text("Best Value unavailable: \(reason)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func availableBody(_ result: FuelSavingsResult) -> some View {
VStack(alignment: .leading, spacing: 14) {
header(result)
panels(result)
workings(result)
}
}
/// Card title on the left, the verdict on the right — ONE capsule, in the
/// same large form the sheet used to repeat on its own line above the
/// working. The comparison caption sits UNDER the capsule, trailing-aligned
/// with it, the same way the listing rows lay the pair out.
///
/// "Value breakdown" rather than "Best Value": the card is the working behind
/// whatever verdict the capsule shows, and that verdict is not always a Best
/// Value one — a Costs station shows its extra cost here. Naming the card after
/// the feature promised the reader a Best Value result it might not get.
private func header(_ result: FuelSavingsResult) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text("Value breakdown")
.font(.headline)
Spacer(minLength: 8)
VStack(alignment: .trailing, spacing: 4) {
FuelSavingsCapsule(result: result, compact: false)
.accessibilityLabel(result.detailCapsuleSpokenHeadline())
comparison(result)
}
}
}
/// The compact comparison caption ("vs closest station" / "your baseline"),
/// under the capsule it belongs to.
private func comparison(_ result: FuelSavingsResult) -> some View {
Text(FuelSavingsFormat.comparisonLabel(status: result.status))
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.trailing)
}
/// The two figures the estimate is made OF, side by side: what the user
/// spends, and how much fuel that buys at this station.
private func panels(_ result: FuelSavingsResult) -> some View {
HStack(spacing: 10) {
panel(symbol: "fuelpump.fill",
title: "Your fill-up",
value: Self.amountText(amount))
panel(symbol: "drop.fill",
title: "Fuel you'll receive",
value: String(format: "%.2f litres", result.fillLitres))
}
}
private func panel(symbol: String, title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 5) {
Image(systemName: symbol)
.font(.caption)
.foregroundStyle(.secondary)
.accessibilityHidden(true)
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
Text(value)
.font(.title3.weight(.semibold))
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(RoundedRectangle(cornerRadius: 10).fill(Color.primary.opacity(0.06)))
.accessibilityElement(children: .combine)
}
/// The working: what the extra travel adds, what the pump saves, and the
/// total. A baseline station has nothing to save against itself, so it stops
/// after the panels — the capsule above already says "Closest".
@ViewBuilder
private func workings(_ result: FuelSavingsResult) -> some View {
if result.status != .baseline {
Divider()
extraTravelRow(result)
savingRows(result)
Divider()
totalRow(result)
if let baselineStationName {
Text("Compared with \(baselineStationName).")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if result.journeyConfidence.isEstimated {
Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
/// The extra driving a saving has to beat, named in the row label and
/// measured in the value — same figure and same "Approx." rule as the shared
/// caption, from the shared formatter.
@ViewBuilder
private func extraTravelRow(_ result: FuelSavingsResult) -> some View {
if let extraTravel = FuelSavingsFormat.extraTravelDistanceLabel(
extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit,
confidence: result.journeyConfidence
) {
LabeledContent("Extra return travel", value: extraTravel)
.monospacedDigit()
}
}
@ViewBuilder
private func savingRows(_ result: FuelSavingsResult) -> some View {
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"
)
.monospacedDigit()
LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP))
.monospacedDigit()
}
/// The Total row is the WORKINGS; the capsule above is the quick verdict.
/// Both take their wording and colour from the shared result.
private func totalRow(_ result: FuelSavingsResult) -> some View {
LabeledContent {
Text(result.headline())
.fontWeight(.semibold)
.foregroundStyle(FuelSavingsCapsule.color(for: result.status))
.monospacedDigit()
} label: {
Text("Total")
.fontWeight(.semibold)
}
}
/// "£60" or "60 litres" — the same strings the sheet printed before the
/// redesign, so the fill-up reads identically in both units.
static func amountText(_ amount: FuelSavingsFillAmount) -> String {
switch amount.unit {
case .pounds: return String(format: "£%.0f", amount.value)
case .litres: return String(format: "%.0f litres", amount.value)
}
}
}
/// "How does FuelBoard calculate value?" lives in the shared `HowBestValueRow`
/// (HowBestValueSheet.swift) — the same row the Key sheet uses, so the sheet and
/// the Key cannot drift.
+193 -90
View File
@@ -17,12 +17,28 @@ struct StationDetailView: View {
let station: FuelStation let station: FuelStation
let location: Coordinate? let location: Coordinate?
let distanceUnit: DistanceUnit let distanceUnit: DistanceUnit
/// Price display style, from the same Settings choice the listing rows read,
/// so the sheet's price can never disagree with the row behind it.
var priceStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
/// Fuel whose price is being shown by the surrounding station list. /// Fuel whose price is being shown by the surrounding station list.
var selectedFuel: FuelType = FuelStore.loadSelectedFuel() var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
var fuelSavings: FuelSavingsAvailability? = nil var fuelSavings: FuelSavingsAvailability? = nil
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
var savingsBaselineStationName: String? = nil var savingsBaselineStationName: String? = nil
var journeyDiagnostics: FuelSavingsJourneyDiagnostics? = nil var journeyDiagnostics: FuelSavingsJourneyDiagnostics? = nil
/// Ranking flags, supplied by the list that opened this sheet — the SAME flag
/// it hands its own rows, so this sheet cannot award a capsule the row behind it
/// did not. Both tabs mean the same thing by "cheapest": the lowest price among
/// the stations they are listing, whether that is the radius or the Favourites.
var isCheapestInRange: Bool = false
/// The same "closest" test the host gives its own rows. Its meaning follows
/// the tab, exactly as Cheapest does: the nearest station in the radius, or
/// the nearest favourite.
var isClosestInRange: Bool = false
/// The user's Best Value alert threshold — the app's existing gate, defaulted
/// exactly as `StationRow` defaults it, so the capsule here and the badge on the
/// list agree to the penny.
var bestValueThresholdGBP: Double = FuelStore.loadAlertsBestValueThresholdGBP()
/// Whether this station is starred for the fuel currently being viewed, and /// Whether this station is starred for the fuel currently being viewed, and
/// the toggle that flips it. Passed from the list that opened the sheet, so /// the toggle that flips it. Passed from the list that opened the sheet, so
/// the star here and the star on the row are the same fuel-scoped /// the star here and the star on the row are the same fuel-scoped
@@ -35,8 +51,8 @@ struct StationDetailView: View {
/// the caption fades out on its own. /// the caption fades out on its own.
@State private var showingCopied = false @State private var showingCopied = false
@State private var copyResetTask: Task<Void, Never>? @State private var copyResetTask: Task<Void, Never>?
/// The "How Best Value works" sheet, opened from the row at the bottom of the /// The "How does FuelBoard calculate value?" sheet, opened from the row at the
/// Best Value card (the Key sheet opens the same shared view). /// bottom of the value-breakdown card (the Key sheet opens the same shared view).
@State private var showBestValueInfo = false @State private var showBestValueInfo = false
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")! private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
@@ -63,12 +79,90 @@ struct StationDetailView: View {
FuelPriceFreshness.assessment(for: station) FuelPriceFreshness.assessment(for: station)
} }
/// The selected grade's current price, when present. Kept separate from /// Which ranking capsules this station qualifies for. All the rules live in the
/// the price-age row so an old price remains visible rather than being /// shared `StationRanking` / `StationRecommendationBadge` pair — nothing is
/// silently hidden. /// recomputed here, and no threshold is invented.
private var selectedFuelPriceText: String { private var rankingKinds: [StationLabelKind] {
guard let price = station.prices[selectedFuel] else { return "—" } StationRanking.kinds(
return FuelStore.priceText(price, style: .stationSign) netSavingGBP: fuelSavings?.result?.netSavingGBP,
isCheapestInRange: isCheapestInRange,
isClosestInRange: isClosestInRange,
bestValueThresholdGBP: bestValueThresholdGBP
)
}
/// One-way and out-and-back distance from the current location, from the ONE
/// shared display helper — so this sheet's figures carry the same confidence
/// marker as the listing row, the widget and the Live Activity.
private var distanceDisplay: StationDistanceDisplay? {
guard let location else { return nil }
return FuelStore.stationDistanceDisplay(
station: station, userLat: location.lat, userLng: location.lng
)
}
/// Header title: the station name over its address, as the mockup shows.
/// Tapping copies the postcode, and the "Postcode copied" capsule floats over
/// the address line it confirms.
///
/// The CARD'S FIRST ROW, above the map — deliberately not a toolbar
/// principal item: a principal item is clamped to one line, so a long
/// forecourt name ("Brighouse Bradford Rd Superstore - Petrol") showed an
/// ellipsis and `minimumScaleFactor` was not honoured. Inside the card it
/// wraps to two lines, and the confirmation capsule cannot be clipped by the
/// nav bar.
private var headerTitle: some View {
Button {
copyPostcode()
} label: {
VStack(alignment: .leading, spacing: 1) {
Text(station.name)
.font(.title3.weight(.semibold))
.multilineTextAlignment(.leading)
.lineLimit(2)
Text(addressLine)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
.lineLimit(2)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.disabled(postcodeForCopying.isEmpty)
.accessibilityLabel(String(localized: "Address: \(addressLine)",
comment: "Accessibility label for the address in the More info sheet header"))
.accessibilityHint(postcodeForCopying.isEmpty
? ""
: String(localized: "Copies the postcode",
comment: "Accessibility hint for the address in the More info sheet header"))
.overlay(alignment: .center) { copiedConfirmation }
}
/// The transient "Postcode copied" capsule. An overlay, so nothing moves
/// while it is up, and hit testing stays off so the header keeps its tap.
@ViewBuilder
private var copiedConfirmation: some View {
if showingCopied {
HStack(spacing: 4) {
Image(systemName: "checkmark")
Text("Postcode copied")
}
.font(.caption.weight(.semibold))
.foregroundStyle(.green)
.padding(.horizontal, 8)
.padding(.vertical, 4)
// A material base keeps it legible where the tint alone (the
// notice-capsule recipe) washes out.
.background {
Capsule()
.fill(.regularMaterial)
.overlay(Capsule().fill(Color.green.opacity(0.22)))
}
.allowsHitTesting(false)
.accessibilityHidden(true)
.transition(.opacity)
}
} }
/// Opens Apple Maps driving directions — the same destination (and the same /// Opens Apple Maps driving directions — the same destination (and the same
@@ -90,8 +184,18 @@ struct StationDetailView: View {
UINotificationFeedbackGenerator().notificationOccurred(.success) UINotificationFeedbackGenerator().notificationOccurred(.success)
withAnimation(.easeInOut(duration: 0.2)) { showingCopied = true } withAnimation(.easeInOut(duration: 0.2)) { showingCopied = true }
copyResetTask?.cancel() copyResetTask?.cancel()
#if DEBUG
// QA hook: `-qaCopyPostcode` holds the confirmation up long enough for a
// screenshot. The production window (2s) cannot be hit reliably — the app
// takes several seconds to render, and simctl cannot tap.
let hold: UInt64 = ProcessInfo.processInfo.arguments.contains("-qaCopyPostcode")
? 25_000_000_000
: 2_000_000_000
#else
let hold: UInt64 = 2_000_000_000
#endif
copyResetTask = Task { copyResetTask = Task {
try? await Task.sleep(nanoseconds: 2_000_000_000) try? await Task.sleep(nanoseconds: hold)
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
withAnimation(.easeInOut(duration: 0.3)) { showingCopied = false } withAnimation(.easeInOut(duration: 0.3)) { showingCopied = false }
} }
@@ -99,8 +203,18 @@ struct StationDetailView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
// Wrapper exists only so the DEBUG scroll hook below can reach a
// section by id; it has no effect on layout or behaviour.
ScrollViewReader { proxy in
List { List {
Section { Section {
// The sheet header is the CARD'S FIRST ROW, above the map:
// the station name over its address, both wrapping rather
// than truncating, on the card's own insets so it lines up
// with the map and the rows below. Tapping it copies the
// postcode. No hairline under it — the map follows.
headerTitle
.listRowSeparator(.hidden, edges: .bottom)
// Tapping anywhere on the map opens the same Apple Maps // Tapping anywhere on the map opens the same Apple Maps
// directions the Directions button below opens. // directions the Directions button below opens.
// //
@@ -140,80 +254,34 @@ struct StationDetailView: View {
// clutter. The Address/Distance and Distance/actions lines // clutter. The Address/Distance and Distance/actions lines
// stay. // stay.
.listRowSeparator(.hidden, edges: .bottom) .listRowSeparator(.hidden, edges: .bottom)
// The map, the address, the distance, any notices and the two // Ranking capsules directly under the map, above the price and
// station actions all share ONE card (the list's own row // distance. Nothing renders when the station qualifies for no
// hairlines separate them), so no hand-rolled Divider here — // label, so an ordinary station's layout is untouched.
// adding one would double the line.
// //
// The station name is NOT repeated: the sheet header already // No hairline under this row either: the map already suppressed
// shows it. // its bottom separator so the header and map read as one block,
// and the facts row below keeps its own line.
if !rankingKinds.isEmpty {
StationRankingCapsules(kinds: rankingKinds)
.listRowSeparator(.hidden, edges: .bottom)
}
// //
// The whole row is a copy control: it puts JUST the postcode // The station name AND the address live in the sheet header,
// on the clipboard so it can be pasted into another // which is also where a tap copies the postcode — see the
// navigation app. The copy glyph sits ON the address line, // principal toolbar item below.
// and the "Postcode copied" confirmation is a capsule that floats OVER // The price and the distance share one two-column row: each
// THIS ROW — it confirms the address, so it must not hover // figure comes from the same source it did before (the price
// over the map. It is an overlay, so nothing moves while it is // from FuelStore, the distance from the one shared display
// up, and `allowsHitTesting(false)` keeps the row tappable // helper), so the row cannot disagree with the listing row,
// through the capsule. // the widget or the Live Activity.
Button { StationFactsRow(
copyPostcode() station: station,
} label: { selectedFuel: selectedFuel,
HStack(alignment: .firstTextBaseline, spacing: 8) { priceAge: priceFreshness,
Text("Address") distance: distanceDisplay,
Spacer(minLength: 8) distanceUnit: distanceUnit,
Text(addressLine) priceStyle: priceStyle
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
// Stays the plain copy glyph — the capsule carries
// the confirmation, so swapping this to a checkmark
// would say it twice.
Image(systemName: "doc.on.doc")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
.disabled(postcodeForCopying.isEmpty)
.accessibilityLabel(String(localized: "Address: \(addressLine)",
comment: "Accessibility label for the address row in the More info sheet"))
.accessibilityHint(postcodeForCopying.isEmpty
? ""
: String(localized: "Copies the postcode",
comment: "Accessibility hint for the address row in the More info sheet"))
// Horizontally centred over the address line itself.
.overlay(alignment: .center) {
if showingCopied {
HStack(spacing: 4) {
Image(systemName: "checkmark")
Text("Postcode copied")
}
.font(.footnote.weight(.semibold))
.foregroundStyle(.green)
.padding(.horizontal, 10)
.padding(.vertical, 6)
// A material base keeps it legible where the tint
// alone (the notice-capsule recipe) washes out.
.background {
Capsule()
.fill(.regularMaterial)
.overlay(Capsule().fill(Color.green.opacity(0.22)))
}
.allowsHitTesting(false)
.accessibilityHidden(true)
.transition(.opacity)
}
}
if let location {
let distance = FuelStore.stationDistanceDisplay(
station: station, userLat: location.lat, userLng: location.lng
) )
LabeledContent("Distance", value: distance.labelWithReturn(unit: distanceUnit))
.monospacedDigit()
}
LabeledContent("\(selectedFuel.displayName) price", value: selectedFuelPriceText)
.monospacedDigit()
StationPriceAgeView(assessment: priceFreshness)
ForEach(StationNotice.notices(for: station)) { notice in ForEach(StationNotice.notices(for: station)) { notice in
Label(notice.title, systemImage: notice.symbolName) Label(notice.title, systemImage: notice.symbolName)
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
@@ -222,15 +290,19 @@ struct StationDetailView: View {
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Capsule().fill(Color.orange.opacity(0.14))) .background(Capsule().fill(Color.orange.opacity(0.14)))
} }
// The two station actions are the LAST row of the card: half // Half the width each, on one line, with the list's hairline
// the width each, on one line, below Address and Distance. // running between them.
HStack(spacing: 12) { HStack(spacing: 0) {
Button { Button {
openDirections() openDirections()
} label: { } label: {
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill") Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
Rectangle()
.fill(Color(.separator))
.frame(width: 0.5, height: 22)
.accessibilityHidden(true)
Button(role: .destructive) { Button(role: .destructive) {
showingReportIssueConfirmation = true showingReportIssueConfirmation = true
} label: { } label: {
@@ -250,16 +322,25 @@ struct StationDetailView: View {
// is not configured — so the heading cannot be left dangling // is not configured — so the heading cannot be left dangling
// over an empty section. // over an empty section.
if let fuelSavings { if let fuelSavings {
// The card carries its own "Value breakdown" heading, so a
// section header would repeat it. Neutral card background: the
// green tint marked the block as the savings card, but it also
// tinted a card whose verdict can be "Costs", and it made this
// one card look like a different material from the station card
// above it.
Section { Section {
FuelSavingsSummaryView( BestValueDetailCard(
availability: fuelSavings, availability: fuelSavings,
amount: fuelSavingsAmount, amount: fuelSavingsAmount,
baselineStationName: savingsBaselineStationName, baselineStationName: savingsBaselineStationName,
distanceUnit: distanceUnit, distanceUnit: distanceUnit
showInfoAction: { showBestValueInfo = true }
) )
} header: { }
Text("Best Value") .id("bestValueCard")
Section {
// The shared row — same component the Key sheet shows.
HowBestValueRow { showBestValueInfo = true }
} }
} }
@@ -276,8 +357,27 @@ struct StationDetailView: View {
#endif #endif
} }
.navigationTitle(station.name) // No navigationTitle: the sheet's header row carries the name and
.navigationBarTitleDisplayMode(.inline) // address, and an inline title would print the name a second time.
#if DEBUG
// QA hook: `-qaDetailBottom` scrolls to the Best Value card so its
// lower half (Extra driving cost, Total, the estimate caveat) can be
// screenshotted. `simctl` cannot scroll, and `.defaultScrollAnchor`
// is a no-op on a List — only scrollTo moves it.
.task {
guard ProcessInfo.processInfo.arguments.contains("-qaDetailBottom") else { return }
try? await Task.sleep(nanoseconds: 3_000_000_000)
withAnimation { proxy.scrollTo("bestValueCard", anchor: .bottom) }
}
.task {
// QA hook: `-qaDetailShowBestValueInfo` opens the explanation from
// THIS sheet (the More info path), so the two-sheet dismissal can
// be screenshotted — simctl has no tap.
guard ProcessInfo.processInfo.arguments.contains("-qaDetailShowBestValueInfo") else { return }
try? await Task.sleep(nanoseconds: 1_500_000_000)
showBestValueInfo = true
}
#endif
.alert("Report an error?", isPresented: $showingReportIssueConfirmation) { .alert("Report an error?", isPresented: $showingReportIssueConfirmation) {
Button("Cancel", role: .cancel) { } Button("Cancel", role: .cancel) { }
Button("Report") { Button("Report") {
@@ -320,5 +420,8 @@ struct StationDetailView: View {
} }
#endif #endif
} }
// Closes the ScrollViewReader wrapped around the List: kept last so
// every List modifier above stays inside `proxy`'s scope.
}
} }
} }
+15
View File
@@ -16,10 +16,25 @@ import SwiftUI
struct StationPriceAgeView: View { struct StationPriceAgeView: View {
let assessment: FuelPriceFreshness.Assessment let assessment: FuelPriceFreshness.Assessment
/// The More info sheet's facts row prints this under the fuel price inside a
/// half-width column, where a `LabeledContent`'s label + value pair cannot
/// fit on one line. Compact keeps the SAME words in one caption line.
var compact: Bool = false
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
if compact {
Text("Price updated \(assessment.updateAgeLabel)")
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.75)
.accessibilityLabel("Price updated \(assessment.updateAgeLabel)")
} else {
LabeledContent("Price updated", value: assessment.updateAgeLabel) LabeledContent("Price updated", value: assessment.updateAgeLabel)
.monospacedDigit() .monospacedDigit()
}
if let notice = assessment.outdatedNotice { if let notice = assessment.outdatedNotice {
Text(notice) Text(notice)
.font(.caption2) .font(.caption2)
+309 -117
View File
@@ -1,6 +1,115 @@
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
/// The "Key" legend: price ratings, savings capsules, station labels, the
/// favourite glyph, then the two deeper explanations. One sheet for both list
/// tabs — Stations opens it from the info button in the navigation bar,
/// Favourites from the button beside Edit — so the legend cannot drift between
/// them.
///
/// Dismisses through the environment rather than the presenting view's state, and
/// takes the two nested-sheet flags as bindings, so each tab keeps owning its own
/// QA launch-argument hooks.
struct KeySheetView: View {
@Binding var showBestValueInfo: Bool
@Binding var showImportantInformation: Bool
@Environment(\.dismiss) private var dismiss
/// Where the user last dragged the sheet. Bound rather than fixed at 0.6 so
/// dragging it up to read the Station labels section sticks, instead of
/// snapping back every time the sheet is reopened.
///
/// The QA launch argument sets the INITIAL detent rather than assigning one
/// after the sheet appears: assigning a new selection while the sheet is still
/// presenting is ignored, which left the legend stuck at 0.6 and the Station
/// labels section off screen.
@State private var detent: PresentationDetent =
ProcessInfo.processInfo.arguments.contains("-qaKeySheetLarge") ? .large : .fraction(0.6)
var body: some View {
NavigationStack {
List {
Section {
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[0]) {
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[0], color: .green)
}
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[1]) {
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[1], color: .orange)
}
KeySheetDescriptionRow(item: KeySheetContent.priceRatings[2]) {
KeyPriceRatingIndicator(item: KeySheetContent.priceRatings[2], color: .red)
}
} header: {
Text("Price ratings")
}
Section {
ForEach(KeySheetContent.savings, id: \.label) { item in
KeySheetDescriptionRow(item: item) {
KeySavingsCapsule(label: item.label)
}
}
} header: {
Text("Savings")
}
Section {
ForEach(StationLabelKind.allCases, id: \.self) { kind in
KeySheetDescriptionRow(item: item(for: kind)) {
StationLabelBadge(kind: kind)
}
}
} header: {
Text("Station labels")
}
Section {
KeySheetDescriptionRow(item: KeySheetContent.favourite) {
FavouriteStarGlyph(isFavourite: true)
}
} header: {
Text("Favourites")
}
Section {
HowBestValueRow { showBestValueInfo = true }
}
Section {
ImportantInformationRow { showImportantInformation = true }
}
}
.navigationTitle("Key")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
// Nested sheet: the Key sheet stays behind the explanation.
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
// Nested sheet: the Key sheet stays behind the disclaimer.
.importantInformationSheet(isPresented: $showImportantInformation)
}
// QA hook: `-qaKeySheetLarge` opens the legend at full height — see the
// `detent` state. The 0.6 detent puts the Station labels section below the
// fold on every device, including the tallest iPhone, so the three label
// badges cannot otherwise be seen on screen.
.presentationDetents([.fraction(0.6), .large], selection: $detent)
.presentationBackground(Color(UIColor.systemGroupedBackground))
}
/// The Key row's copy for a label kind. Fails loudly in debug rather than
/// silently rendering a blank row if a kind is ever added without its entry.
private func item(for kind: StationLabelKind) -> KeySheetContent.Item {
if let match = KeySheetContent.stationLabels.first(where: { $0.label == kind.label }) {
return match
}
assertionFailure("No KeySheetContent.stationLabels entry for \(kind)")
return KeySheetContent.Item(label: kind.label, description: "")
}
}
/// Stations tab — the main list with fuel picker, distance filter, sort, pagination. /// Stations tab — the main list with fuel picker, distance filter, sort, pagination.
struct StationsView: View { struct StationsView: View {
let stations: [FuelStation] let stations: [FuelStation]
@@ -12,7 +121,11 @@ struct StationsView: View {
let distanceUnit: DistanceUnit let distanceUnit: DistanceUnit
let priceDisplayStyle: PriceDisplayStyle let priceDisplayStyle: PriceDisplayStyle
let baselinePrice: Double? let baselinePrice: Double?
let topStationID: String? let cheapestStationID: String?
/// The station the savings are measured against — the closest station with an
/// eligible price. Passed in from the owner of the scaffold baseline so this
/// list's Closest badge and the reference station cannot disagree.
let closestStationID: String?
let location: Coordinate? let location: Coordinate?
let favouriteIDs: Set<String> let favouriteIDs: Set<String>
let stationTapAction: StationTapAction let stationTapAction: StationTapAction
@@ -24,7 +137,7 @@ struct StationsView: View {
var onRefresh: () async -> Void = {} var onRefresh: () async -> Void = {}
/// Pagination: one page = 10 rows, reset whenever the underlying list /// Pagination: one page = 10 rows, reset whenever the underlying list
/// changes (new fetch, fuel/sort/radius switch). RAG/TOP/deltas still come /// changes (new fetch, fuel/sort/radius switch). RAG/badge/deltas still come
/// from the whole radius pool via `cheapestPrice` — paging never changes /// from the whole radius pool via `cheapestPrice` — paging never changes
/// which station is "best". /// which station is "best".
@State private var pageSize = 10 @State private var pageSize = 10
@@ -39,6 +152,11 @@ struct StationsView: View {
/// the Key sheet and from the More info sheet. /// the Key sheet and from the More info sheet.
@State private var showBestValueInfo = false @State private var showBestValueInfo = false
/// The shared "Important information" disclaimer sheet. It lives at the
/// bottom of the Key sheet here; the same sheet is reused wherever else the
/// disclaimer is needed.
@State private var showImportantInformation = false
/// Station picked for the More-info sheet (tap action = showDetails). /// Station picked for the More-info sheet (tap action = showDetails).
@State private var detailStation: FuelStation? @State private var detailStation: FuelStation?
@@ -123,6 +241,13 @@ struct StationsView: View {
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount) FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
} }
if fuelSavingsSettings.isConfigured && !stations.isEmpty {
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)
}
if isLoading && stations.isEmpty { if isLoading && stations.isEmpty {
HStack(spacing: 10) { HStack(spacing: 10) {
ProgressView() ProgressView()
@@ -216,8 +341,28 @@ struct StationsView: View {
showBestValueInfo = true showBestValueInfo = true
} }
} }
// QA hook: `-showImportantInfo` opens the Key sheet and then the
// shared disclaimer on top of it, so the copy can be
// screenshotted without a tap. Same chained requirement as
// `-showBestValueInfo` above.
if ImportantInformationQA.isRequested {
showKey = true
Task { @MainActor in
try? await Task.sleep(nanoseconds: 1_200_000_000)
showImportantInformation = true
}
}
#endif #endif
} }
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardOpenBestValueSettings)) { _ in
// "Setup Best Value" from the explanation sheet. That sheet closes
// itself and ContentView switches to Settings, but both of the
// sheets this view can have open are presented from HERE and each
// survived the tab switch, floating over Settings: the Key sheet
// and the More info sheet. Close whichever is up.
showKey = false
detailStation = nil
}
.task(id: stations.count) { .task(id: stations.count) {
#if DEBUG #if DEBUG
// QA hook: `-showDetail` opens the More info sheet for the first // QA hook: `-showDetail` opens the More info sheet for the first
@@ -254,96 +399,28 @@ struct StationsView: View {
station: station, station: station,
location: location, location: location,
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
priceStyle: priceDisplayStyle,
selectedFuel: selectedFuel, selectedFuel: selectedFuel,
fuelSavings: savingsForStation(station), fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount, fuelSavingsAmount: fuelSavingsAmount,
journeyDiagnostics: journeyDiagnosticsForStation(station), journeyDiagnostics: journeyDiagnosticsForStation(station),
// The same flag the row gets from `cheapestStationID`.
isCheapestInRange: station.id == cheapestStationID,
// The same flag the row gets from `closestStationID`.
isClosestInRange: station.id == closestStationID,
isFavourite: favouriteIDs.contains(station.id), isFavourite: favouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station, selectedFuel) } onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
) )
} }
/// The legend sheet, shared with the Favourites tab. Nothing varies by fuel:
/// every station label has a fixed semantic tone, so the legend reads the same
/// whichever grade the reader is looking at.
private var keySheet: some View { private var keySheet: some View {
NavigationStack { KeySheetView(
List { showBestValueInfo: $showBestValueInfo,
Section { showImportantInformation: $showImportantInformation
VStack(spacing: 10) { )
keyCard(
icon: "checkmark.circle.fill", color: .green,
title: "Good price", blurb: "Within 1.5p of the cheapest",
threshold: "0–1.5p")
keyCard(
icon: "equal.circle.fill", color: .orange,
title: "Okay", blurb: "More than 1.5p, up to 4p above the cheapest",
threshold: ">1.5–4p")
keyCard(
icon: "exclamationmark.circle.fill", color: .red,
title: "Pricey", blurb: "More than 4p above the cheapest",
threshold: ">4p")
Rectangle()
.fill(Color(.separator))
.frame(height: 0.5)
.padding(.vertical, 2)
HStack(spacing: 12) {
Text("TOP")
.font(.caption2.bold())
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Capsule().fill(.blue.opacity(0.16)))
.foregroundStyle(.blue)
Text("Top result for the current sort")
.font(.subheadline.weight(.semibold))
Spacer(minLength: 0)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.keyCardFill(color: .blue)
HStack(spacing: 12) {
Image(systemName: "star.fill")
.font(.title3)
.foregroundStyle(.yellow)
.frame(width: 34)
Text("Star a station to add it to Favourites")
.font(.subheadline.weight(.semibold))
Spacer(minLength: 0)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.keyCardFill(color: .yellow)
Text("Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.")
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.multilineTextAlignment(.center)
.padding(.top, 4)
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
}
// The explanation is its own sheet now, opened from this
// row. Gated exactly like the More info row and the Best
// Value card itself: `isConfigured` is the same condition
// `savingsForStation` uses, so the row appears and
// disappears with the feature.
if fuelSavingsSettings.isConfigured {
Section {
HowBestValueRow { showBestValueInfo = true }
}
}
}
.navigationTitle("Key")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { showKey = false }
}
}
// Nested sheet: the Key sheet stays behind the explanation.
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
}
.presentationDetents([.fraction(0.6)])
.presentationBackground(Color(UIColor.systemGroupedBackground))
} }
private func stationRow(_ station: FuelStation) -> some View { private func stationRow(_ station: FuelStation) -> some View {
@@ -354,7 +431,8 @@ struct StationsView: View {
distanceUnit: distanceUnit, distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle, priceDisplayStyle: priceDisplayStyle,
baselinePrice: baselinePrice, baselinePrice: baselinePrice,
isTopResult: station.id == topStationID, isCheapestResult: station.id == cheapestStationID,
isClosestResult: station.id == closestStationID,
isFavourite: favouriteIDs.contains(station.id), isFavourite: favouriteIDs.contains(station.id),
tapAction: stationTapAction, tapAction: stationTapAction,
fuelSavings: savingsForStation(station), fuelSavings: savingsForStation(station),
@@ -371,49 +449,163 @@ struct StationsView: View {
return modes return modes
} }
/// One price-rating card for the Key sheet: accent icon, title + blurb, }
/// and the rating threshold in the accent colour (mirrors the tip cards).
private func keyCard(icon: String, color: Color, title: String, blurb: String, threshold: String) -> some View { /// The station list's real RAG dot, paired with the rating name. The range lives
HStack(spacing: 12) { /// in the aligned description column rather than as a separate trailing value.
Image(systemName: icon) private struct KeyPriceRatingIndicator: View {
.font(.title3) let item: KeySheetContent.Item
.foregroundStyle(color) let color: Color
.frame(width: 34)
VStack(alignment: .leading, spacing: 2) { var body: some View {
Text(LocalizedStringKey(title)).font(.headline) HStack(spacing: 5) {
Text(LocalizedStringKey(blurb)) PriceRatingDot(color: color)
.font(.caption) Text(LocalizedStringKey(item.label))
.foregroundStyle(.secondary) .font(.caption.weight(.semibold))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
Spacer(minLength: 8)
HStack(spacing: 6) {
Circle().fill(color).frame(width: 10, height: 10)
Text(threshold)
.font(.headline.weight(.bold))
.foregroundStyle(color)
.monospacedDigit()
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.keyCardFill(color: color)
} }
} }
/// Card background shared by the Key sheet cards — subtle accent tint over /// Every Key section shares this one two-column row. The scaled first-column
/// the grouped background, matching the tip-section card style. /// width keeps every description on the same leading axis while preserving the
private extension View { /// inline layout at accessibility sizes; descriptions own the flexible column
func keyCardFill(color: Color) -> some View { /// and grow vertically without truncating.
background( private struct KeySheetDescriptionRow<Indicator: View>: View {
RoundedRectangle(cornerRadius: 12) private let indicatorColumnWidth: CGFloat = 128
.fill(Color(.secondarySystemGroupedBackground))
.overlay(RoundedRectangle(cornerRadius: 12).fill(color.opacity(0.08))) let item: KeySheetContent.Item
@ViewBuilder let indicator: Indicator
var body: some View {
HStack(alignment: .top, spacing: 12) {
indicator
.frame(width: indicatorColumnWidth, alignment: .leading)
description
}
.padding(.vertical, 3)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(item.label). \(item.description)")
}
private var description: some View {
Text(LocalizedStringKey(item.description))
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
/// Uses the same listing capsule component as real station rows, with neutral
/// fixture numbers: only the verdict is visible in this compact variant.
private struct KeySavingsCapsule: View {
let label: String
/// Keyed on the shared label so the legend cannot demonstrate a word the
/// listing capsule never draws.
private var status: FuelSavingsStatus {
switch label {
case String(localized: "Saves"): return .worthTheDrive
case String(localized: "Costs"): return .notWorthTheDrive
default: return .breakEven
}
}
private var result: FuelSavingsResult {
let netSaving = status == .worthTheDrive ? 1.0 : (status == .notWorthTheDrive ? -1.0 : 0)
return FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: netSaving,
extraDrivingCostGBP: 0,
extraDistanceMiles: 0,
netSavingGBP: netSaving,
status: status,
journeyConfidence: .confirmed
) )
} }
var body: some View {
// The legend demonstrates the wording, not a figure: `result` above is a
// placeholder verdict, and its £1.00 must never reach the screen.
ListingFuelSavingsCapsule(result: result, showsAmount: false)
}
} }
/// Shared RAG dot used beside live station prices and in the Key.
///
/// 12pt is the app's one diameter: it reads at a glance beside a 22pt price
/// without competing with it, and it is large enough that the three colours are
/// distinguishable for colour-vision-deficient users at arm's length. The widget
/// keeps a smaller dot of its own — its rows are far tighter than a phone row —
/// so this size is deliberately the APP's.
struct PriceRatingDot: View {
let color: Color
var diameter: CGFloat = PriceRatingDot.standardDiameter
/// The app's standard dot diameter, in points.
static let standardDiameter: CGFloat = 12
var body: some View {
Circle()
.fill(color)
.frame(width: diameter, height: diameter)
.accessibilityHidden(true)
}
}
/// Shared favourite glyph used by station rows and the Key.
struct FavouriteStarGlyph: View {
let isFavourite: Bool
var body: some View {
Image(systemName: isFavourite ? "star.fill" : "star")
.foregroundStyle(isFavourite ? .yellow : .secondary)
}
}
/// Shared station-label capsule. `StationRow` uses the same view for its live
/// BEST VALUE/CHEAPEST badge, so the Key cannot drift from the listing style.
///
/// One tone per label kind, from the shared palette: purple for best value, blue
/// for cheapest and top (a top favourite IS the cheapest favourite, so they share
/// a class), neutral for proximity. The symbol is the label's own meaning, sized
/// to sit on the caption's cap height rather than to dominate it.
struct StationLabelBadge: View {
let kind: StationLabelKind
private var tone: CapsuleTone {
switch kind {
case .bestValue: return .bestValue
case .cheapest: return .cheapest
case .closest: return .closest
}
}
var body: some View {
HStack(spacing: 3) {
Image(systemName: kind.symbol)
.font(.caption2.weight(.semibold))
.accessibilityHidden(true)
Text(LocalizedStringKey(kind.label))
.font(.caption.weight(.semibold))
.fixedSize(horizontal: true, vertical: false)
}
.foregroundStyle(tone.foreground)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Capsule().fill(tone.background))
}
}
/// Every station label in the Key is the SAME `StationLabelBadge` the listing rows
/// and the More info sheet draw.
///
/// The closest station used to be the exception — the legend rendered the real
/// verdict capsule for it, a wider pill with different fill and shape, so it read as
/// a different class of thing from Best value and Cheapest sitting above it. Now
/// that its label is the single word "Closest", it belongs in the same column, in
/// the same capsule, at the same size.
// MARK: - Fuel-type iconography (app target only — FuelStore.swift is Foundation-only) // MARK: - Fuel-type iconography (app target only — FuelStore.swift is Foundation-only)
extension FuelType { extension FuelType {
+5
View File
@@ -4,6 +4,11 @@ import WatchConnectivity
extension Notification.Name { extension Notification.Name {
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested") static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
static let fuelBoardShowStationDetail = Notification.Name("FuelBoardShowStationDetail") static let fuelBoardShowStationDetail = Notification.Name("FuelBoardShowStationDetail")
/// Posted by the "How Best Value works" sheet's Setup Best Value button, so
/// the app can close that sheet's stack and land on Settings — whose Best
/// Value section is the first thing in its list. The sheet cannot switch
/// tabs itself: it is presented two levels below the tab view.
static let fuelBoardOpenBestValueSettings = Notification.Name("FuelBoardOpenBestValueSettings")
/// Posted when a road-distance fill finishes, so the app's rows re-read the /// Posted when a road-distance fill finishes, so the app's rows re-read the
/// distance they are showing — those reads happen inside the row views, so /// distance they are showing — those reads happen inside the row views, so
/// nothing else would invalidate them. /// nothing else would invalidate them.
+79 -27
View File
@@ -9,9 +9,9 @@
"Alerts" = "Alerts"; "Alerts" = "Alerts";
"Settings" = "Settings"; "Settings" = "Settings";
"FuelBoard" = "FuelBoard"; "FuelBoard" = "FuelBoard";
"TOP" = "TOP"; "Best value" = "Best value";
"BEST VALUE" = "BEST VALUE"; "Cheapest" = "Cheapest";
"CHEAPEST" = "CHEAPEST"; "Closest" = "Closest";
/* Stations tab */ /* Stations tab */
"Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations" = "Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations"; "Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations" = "Closest %@ stations — closest first, lowest price within %lld %@ · %lld stations";
@@ -25,23 +25,40 @@
"Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)"; "Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)";
"All %lld stations shown" = "All %lld stations shown"; "All %lld stations shown" = "All %lld stations shown";
"Key" = "Key"; "Key" = "Key";
"Price ratings" = "Price ratings";
"Good price" = "Good price"; "Good price" = "Good price";
"Within 1.5p of the cheapest" = "Within 1.5p of the cheapest"; "Within 1.5p/L of the cheapest." = "Within 1.5p/L of the cheapest.";
"Okay" = "Okay"; "Okay" = "Okay";
"More than 1.5p, up to 4p above the cheapest" = "More than 1.5p, up to 4p above the cheapest"; "More than 1.5p/L, up to 4p/L above the cheapest." = "More than 1.5p/L, up to 4p/L above the cheapest.";
"Pricey" = "Pricey"; "Pricey" = "Pricey";
"More than 4p above the cheapest" = "More than 4p above the cheapest"; "More than 4p/L above the cheapest." = "More than 4p/L above the cheapest.";
"Top result for the current sort" = "Top result for the current sort"; "Savings" = "Savings";
"Star a station to add it to Favourites" = "Star a station to add it to Favourites"; "Saves" = "Saves";
"You’ll save money compared with your closest station, including travel costs." = "You’ll save money compared with your closest station, including travel costs.";
"Break even" = "Break even";
"The saving or extra cost is negligible." = "The saving or extra cost is negligible.";
"Costs" = "Costs";
"Filling up here costs more than at your closest station, including travel." = "Filling up here costs more than at your closest station, including travel.";
/* Station listing verdict capsule: the words above the figure */
"(approx.)" = "(approx.)";
"%@ more" = "%@ more";
"Station labels" = "Station labels";
"Meets your minimum saving after travel costs." = "Meets your minimum saving after travel costs.";
"The lowest fuel price per litre within your selected range." = "The lowest fuel price per litre within your selected range.";
"The closest station by journey distance." = "The closest station by journey distance.";
"Star a station to save it to your Favourites." = "Star a station to save it to your Favourites.";
"How does FuelBoard calculate value?" = "How does FuelBoard calculate value?";
/* Title of the explanation sheet the row above opens (a short form of it). */
"How value is calculated" = "How value is calculated";
"Value breakdown" = "Value breakdown";
/* Star button in the More info sheet heading (accessibility labels) */ /* Star button in the More info sheet heading (accessibility labels) */
"Add to favourites" = "Add to favourites"; "Add to favourites" = "Add to favourites";
"Remove from favourites" = "Remove from favourites"; "Remove from favourites" = "Remove from favourites";
"Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list." = "Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list."; "Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list." = "Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.";
"How Best Value works" = "How Best Value works";
"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." = "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."; "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." = "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.";
"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." = "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."; "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." = "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.";
"Your closest station by journey distance is the baseline — every other saving is measured against it." = "Your closest station by journey distance is the baseline — every other saving is measured against it."; "Your closest station by journey distance is the baseline — every other saving is measured against it." = "Your closest station by journey distance is the baseline — every other saving is measured against it.";
"Savings below are estimated against the closest station by journey distance — the same baseline as the Stations list." = "Savings below are estimated against the closest station by journey distance — the same baseline as the Stations list."; "Price colours show pump price competitiveness. Savings compare with your closest station, including journey costs." = "Price colours show pump price competitiveness. Savings compare with your closest station, including journey costs.";
"Your fill-up amount and MPG are used to show whether you could save overall." = "Your fill-up amount and MPG are used to show whether you could save overall."; "Your fill-up amount and MPG are used to show whether you could save overall." = "Your fill-up amount and MPG are used to show whether you could save overall.";
"Results are marked “Approx.” until road distances are available." = "Results are marked “Approx.” until road distances are available."; "Results are marked “Approx.” until road distances are available." = "Results are marked “Approx.” until road distances are available.";
@@ -58,19 +75,18 @@
"Radius" = "Radius"; "Radius" = "Radius";
"%lld %@" = "%lld %@"; "%lld %@" = "%lld %@";
"Follow search (%@)" = "Follow search (%@)"; "Follow search (%@)" = "Follow search (%@)";
"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." = "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.";
"No stations monitored yet — open the Stations tab to load prices first." = "No stations monitored yet — open the Stations tab to load prices first."; "No stations monitored yet — open the Stations tab to load prices first." = "No stations monitored yet — open the Stations tab to load prices first.";
"Last alert" = "Last alert"; "Last alert" = "Last alert";
"Live Activity" = "Live Activity"; "Live Activity" = "Live Activity";
"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." = "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.";
/* Settings tab */ /* Settings tab */
"Units" = "Units"; "Units" = "Units";
"Price display" = "Price display"; "Price display" = "Price display";
"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)." = "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)."; "Choose how distances are displayed throughout FuelBoard." = "Choose how distances are displayed throughout FuelBoard.";
"Show prices as 129.9p or £1.299 per litre." = "Show prices as 129.9p or £1.299 per litre.";
"Tap station" = "Tap station"; "Tap station" = "Tap station";
"Tap action" = "Tap action"; "Tap action" = "Tap action";
"Tapping a station either opens directions straight away or shows its details first." = "Tapping a station either opens directions straight away or shows its details first."; "Choose what happens when you tap a station: get directions or view its details." = "Choose what happens when you tap a station: get directions or view its details.";
"Open map" = "Open map"; "Open map" = "Open map";
"More info" = "More info"; "More info" = "More info";
"Name" = "Name"; "Name" = "Name";
@@ -81,7 +97,18 @@
"Directions to %@" = "Directions to %@"; "Directions to %@" = "Directions to %@";
"Opens Apple Maps with driving directions" = "Opens Apple Maps with driving directions"; "Opens Apple Maps with driving directions" = "Opens Apple Maps with driving directions";
"Show introduction" = "Replay onboarding"; "Show introduction" = "Replay onboarding";
"Replay the welcome screen, including the location and notification permission prompts." = "Replay the welcome screen, including the location and notification permission prompts."; "View the welcome screens and set up permissions again." = "View the welcome screens and set up permissions again.";
/* Settings — refined copy (Fuel Savings, Distances, Data checking) */
"Fill-up amount" = "Fill-up amount";
"Find stations that could save you money, even after the cost of driving there." = "Find stations that could save you money, even after the cost of driving there.";
"How many miles your car travels per gallon." = "How many miles your car travels per gallon.";
"How much you plan to spend on fuel." = "How much you plan to spend on fuel.";
"How many litres you usually buy." = "How many litres you usually buy.";
"The smallest saving you'd consider worth the extra drive." = "The smallest saving you'd consider worth the extra drive.";
"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." = "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.";
"Use actual driving distances instead of straight-line estimates. Routes are calculated while the app is open." = "Use actual driving distances instead of straight-line estimates. Routes are calculated while the app is open.";
"Background updates depend on when your iPhone allows them." = "Background updates depend on when your iPhone allows them.";
"Test alert notification (real data)" = "Test alert notification (real data)"; "Test alert notification (real data)" = "Test alert notification (real data)";
"Send plain test notification" = "Send plain test notification"; "Send plain test notification" = "Send plain test notification";
"Debug" = "Debug"; "Debug" = "Debug";
@@ -202,8 +229,6 @@
/* Alerts and favourites localisation pass */ /* Alerts and favourites localisation pass */
"Cheapest favourite" = "Cheapest favourite"; "Cheapest favourite" = "Cheapest favourite";
"Check now" = "Check now"; "Check now" = "Check now";
"Price-drop alerts" = "Price-drop alerts";
"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." = "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.";
"Cheapest station" = "Cheapest station"; "Cheapest station" = "Cheapest station";
"Alert type" = "Alert type"; "Alert type" = "Alert type";
"Best Value" = "Best Value"; "Best Value" = "Best Value";
@@ -212,19 +237,15 @@
"Minimum saving %@ — change in Settings, Fuel Savings." = "Minimum saving %@ — change in Settings, Fuel Savings."; "Minimum saving %@ — change in Settings, Fuel Savings." = "Minimum saving %@ — change in Settings, Fuel Savings.";
"Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost." = "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost."; "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost." = "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.";
"Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost." = "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost."; "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost." = "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.";
"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." = "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.";
"Proximity alerts" = "Proximity alerts"; "Proximity alerts" = "Proximity alerts";
"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." = "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.";
"Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing)." = "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing).";
"Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips." = "Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips.";
"City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town." = "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town.";
"Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns." = "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns.";
"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." = "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.";
"Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires." = "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires.";
"%@ — %@" = "%@ — %@";
/* Best Value calculation and station price age */ /* Best Value calculation and station price age */
"£ compares the litres your budget buys at each station; Litres compares one fixed quantity." = "£ compares the litres your budget buys at each station; Litres compares one fixed quantity."; "How much fuel?" = "How much fuel?";
"Adjust your fill-up amount for this visit. Your default won’t change." = "Adjust your fill-up amount for this visit. Your default won’t change.";
"About comparison modes" = "About comparison modes";
"Comparison modes" = "Comparison modes";
"£: Compare how much fuel your budget buys at each station." = "£: Compare how much fuel your budget buys at each station.";
"Litres: Compare the cost of buying the same amount of fuel." = "Litres: Compare the cost of buying the same amount of fuel.";
"You pay the selected budget here. The saving compares the same litres at your closest station; litres differ between stations, and this is not a refund." = "You pay the selected budget here. The saving compares the same litres at your closest station; litres differ between stations, and this is not a refund."; "You pay the selected budget here. The saving compares the same litres at your closest station; litres differ between stations, and this is not a refund." = "You pay the selected budget here. The saving compares the same litres at your closest station; litres differ between stations, and this is not a refund.";
"Best Value unavailable · location needed" = "Best Value unavailable · location needed"; "Best Value unavailable · location needed" = "Best Value unavailable · location needed";
"your order" = "your order"; "your order" = "your order";
@@ -234,3 +255,34 @@
"May be outdated" = "May be outdated"; "May be outdated" = "May be outdated";
"Fuel you'll receive" = "Fuel you'll receive"; "Fuel you'll receive" = "Fuel you'll receive";
"Compared with your closest station." = "Compared with your closest station."; "Compared with your closest station." = "Compared with your closest station.";
/* Alerts tab — refined copy (sections, descriptions, limitations) */
"Favourite price alerts" = "Favourite price alerts";
"Get notified when a different favourite becomes the cheapest, or your cheapest favourite drops in price." = "Get notified when a different favourite becomes the cheapest, or your cheapest favourite drops in price.";
"Get notified when you're near one of the cheapest stations for your chosen fuel." = "Get notified when you're near one of the cheapest stations for your chosen fuel.";
"Get notified when you're near a station that could save you money after travel costs." = "Get notified when you're near a station that could save you money after travel costs.";
"Get alerts when you're within %@ of a matching station." = "Get alerts when you're within %@ of a matching station.";
"Get alerts when you're within %@ of a matching station (your search distance is capped for reliable alerts)." = "Get alerts when you're within %@ of a matching station (your search distance is capped for reliable alerts).";
"Uses the same distance as your station search." = "Uses the same distance as your station search.";
"See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel." = "See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel.";
"Background alerts depend on when your iPhone allows them; each station alerts at most once an hour." = "Background alerts depend on when your iPhone allows them; each station alerts at most once an hour.";
/* Important information — the shared disclaimer sheet (one card per heading) */
"Important information" = "Important information";
"Fuel Prices" = "Fuel Prices";
"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." = "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." = "While we aim to provide accurate, up-to-date information, we cannot guarantee that displayed prices reflect those currently available at the pump.";
"Fuel Availability" = "Fuel Availability";
"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." = "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." = "We recommend checking availability before making a special journey.";
"Savings & Best Value" = "Savings & Best Value";
"Savings and Best Value recommendations are estimates based on available fuel prices, your selected fill-up amount, estimated travel costs and other relevant settings." = "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." = "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.";
"Journey Estimates" = "Journey Estimates";
"Travel distances, journey times and associated fuel costs are estimates. Where actual road distances are unavailable, approximate distances may be used." = "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." = "Actual journeys may differ due to traffic, road conditions, diversions and individual driving habits.";
"Before You Travel" = "Before You Travel";
"Fuel prices and availability can change without notice. Always check the current price and fuel availability before making a special journey or filling up." = "Fuel prices and availability can change without notice. Always check the current price and fuel availability before making a special journey or filling up.";
"Disclaimer" = "Disclaimer";
"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 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." = "FuelBoard accepts no responsibility for additional travel costs, financial losses or decisions made using the information provided.";
@@ -357,6 +357,68 @@ final class AlertsFuelTests: XCTestCase {
} }
} }
/// The Settings sliders save ONE field per gesture instead of rewriting all four
/// keys (four UserDefaults writes plus four keychain round trips per drag is what
/// made them stutter). These pin the store half of that: a single-field save must
/// leave the other three keys exactly as they were.
final class FuelSavingsFieldSaveTests: XCTestCase {
private func seed() {
FuelStore.saveFuelSavingsSettings(FuelSavingsSettings(
enabled: true,
mpg: 40,
preferredAmount: FuelSavingsFillAmount(value: 50, unit: .pounds)
))
}
func testSavingMPGLeavesTheOtherFieldsAlone() {
seed()
var edited = FuelStore.loadFuelSavingsSettings()
edited.mpg = 62
FuelStore.saveFuelSavingsField(.mpg, from: edited)
let reloaded = FuelStore.loadFuelSavingsSettings()
XCTAssertEqual(reloaded.mpg, 62, "the edited field is written")
XCTAssertTrue(reloaded.enabled, "enabled survives an MPG save")
XCTAssertEqual(reloaded.preferredAmount.value, 50, "the fill-up amount survives an MPG save")
XCTAssertEqual(reloaded.preferredAmount.unit, .pounds, "the unit survives an MPG save")
}
func testSavingFillAmountLeavesMPGAndUnitAlone() {
seed()
var edited = FuelStore.loadFuelSavingsSettings()
edited.preferredAmount.value = 75
FuelStore.saveFuelSavingsField(.amount, from: edited)
let reloaded = FuelStore.loadFuelSavingsSettings()
XCTAssertEqual(reloaded.preferredAmount.value, 75, "the edited field is written")
XCTAssertEqual(reloaded.mpg, 40, "MPG survives a fill-up save")
XCTAssertEqual(reloaded.preferredAmount.unit, .pounds, "the unit survives a fill-up save")
}
func testSavingEnabledLeavesTheNumbersAlone() {
seed()
var edited = FuelStore.loadFuelSavingsSettings()
edited.enabled = false
FuelStore.saveFuelSavingsField(.enabled, from: edited)
let reloaded = FuelStore.loadFuelSavingsSettings()
XCTAssertFalse(reloaded.enabled, "the edited field is written")
XCTAssertEqual(reloaded.mpg, 40, "MPG survives an enabled save")
XCTAssertEqual(reloaded.preferredAmount.value, 50, "the fill-up amount survives an enabled save")
}
func testWholeSettingsSaveStillWritesEveryField() {
seed()
FuelStore.saveFuelSavingsSettings(FuelSavingsSettings(
enabled: false,
mpg: 55,
preferredAmount: FuelSavingsFillAmount(value: 30, unit: .litres)
))
let reloaded = FuelStore.loadFuelSavingsSettings()
XCTAssertFalse(reloaded.enabled)
XCTAssertEqual(reloaded.mpg, 55)
XCTAssertEqual(reloaded.preferredAmount.value, 30)
XCTAssertEqual(reloaded.preferredAmount.unit, .litres)
}
}
final class AlertRadiusFollowTests: XCTestCase { final class AlertRadiusFollowTests: XCTestCase {
func testAlertRadiusOptionsAreMileFriendly() { func testAlertRadiusOptionsAreMileFriendly() {
// 1–2 city, 3 town, 5 default, 8 motorway — the geofence ceiling. // 1–2 city, 3 town, 5 default, 8 motorway — the geofence ceiling.
@@ -787,6 +849,12 @@ final class RoadDistanceCacheTests: XCTestCase {
XCTAssertEqual(confirmed.labelWithReturn(unit: .kilometers), "3.2 km (6.8 km return)") XCTAssertEqual(confirmed.labelWithReturn(unit: .kilometers), "3.2 km (6.8 km return)")
XCTAssertEqual(confirmed.spokenLabel(unit: .miles), "2.0 miles") XCTAssertEqual(confirmed.spokenLabel(unit: .miles), "2.0 miles")
XCTAssertEqual(confirmed.spokenLabelWithReturn(unit: .miles), "2.0 miles, 4.2 miles return") XCTAssertEqual(confirmed.spokenLabelWithReturn(unit: .miles), "2.0 miles, 4.2 miles return")
// The return figure split out on its own — a surface that prints the
// one-way and the return distance on separate lines still gets BOTH from
// this type, marker included.
XCTAssertEqual(confirmed.returnLabel(unit: .miles), "4.2 mi return")
XCTAssertEqual(confirmed.returnLabel(unit: .kilometers), "6.8 km return")
XCTAssertEqual(confirmed.spokenReturnLabel(unit: .miles), "4.2 miles return")
let approximate = FuelStore.stationDistanceDisplay(station: unrouted, userLat: 51.6, userLng: -0.1) let approximate = FuelStore.stationDistanceDisplay(station: unrouted, userLat: 51.6, userLng: -0.1)
XCTAssertTrue(approximate.isApproximate, "the straight-line fallback must be identified as one") XCTAssertTrue(approximate.isApproximate, "the straight-line fallback must be identified as one")
@@ -828,6 +896,10 @@ final class RoadDistanceCacheTests: XCTestCase {
XCTAssertFalse(display.returnIsApproximate) XCTAssertFalse(display.returnIsApproximate)
XCTAssertEqual(display.labelWithReturn(unit: .miles), "2.9 mi (5.9 mi return)") XCTAssertEqual(display.labelWithReturn(unit: .miles), "2.9 mi (5.9 mi return)")
XCTAssertEqual(display.spokenLabelWithReturn(unit: .miles), "2.9 miles, 5.9 miles return") XCTAssertEqual(display.spokenLabelWithReturn(unit: .miles), "2.9 miles, 5.9 miles return")
// The split-out return figure must use the INDEPENDENT return route
// (9.47 km), not a doubled one-way (2 × 4.65 = 9.30 km → 5.8 mi).
XCTAssertEqual(display.returnLabel(unit: .miles), "5.9 mi return")
XCTAssertEqual(display.spokenReturnLabel(unit: .miles), "5.9 miles return")
} }
/// The marker tracks the cache on its own: same station, bare figure once its /// The marker tracks the cache on its own: same station, bare figure once its
@@ -533,6 +533,6 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
)?.result )?.result
XCTAssertEqual(result?.status, .baseline) XCTAssertEqual(result?.status, .baseline)
XCTAssertEqual(result?.netSavingGBP, 0) XCTAssertEqual(result?.netSavingGBP, 0)
XCTAssertEqual(result?.headline(), "Closest station") XCTAssertEqual(result?.headline(), "Closest")
} }
} }
@@ -18,6 +18,70 @@ final class FuelSavingsEstimateTests: XCTestCase {
) )
private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds) private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
func testListingCapsulePutsTheFigureOnItsOwnLine() {
func result(_ status: FuelSavingsStatus, _ confidence: JourneyConfidence) -> FuelSavingsResult {
FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: status == .notWorthTheDrive ? 0 : 12.50,
extraDrivingCostGBP: status == .notWorthTheDrive ? 123.45 : 0,
extraDistanceMiles: 2,
netSavingGBP: status == .notWorthTheDrive ? -123.45 : 12.50,
status: status,
journeyConfidence: confidence
)
}
// The figure takes its own line, so the capsule's shape is decided by the
// data rather than by how much width the price column hands it.
XCTAssertEqual(result(.worthTheDrive, .confirmed).listingVerdict().lines,
["Saves", "£12.50"])
XCTAssertEqual(result(.worthTheDrive, .estimated).listingVerdict().lines,
["Saves", "£12.50", "(approx.)"])
XCTAssertEqual(result(.notWorthTheDrive, .confirmed).listingVerdict().lines,
["Costs", "£123.45 more"])
XCTAssertEqual(result(.notWorthTheDrive, .estimated).listingVerdict().lines,
["Costs", "£123.45 more", "(approx.)"])
// Break even carries no figure — ±£0.05 is noise — so it stays one line
// when confirmed and gains a second only for the estimate qualifier.
XCTAssertEqual(result(.breakEven, .confirmed).listingVerdict().lines, ["Break even"])
XCTAssertEqual(result(.breakEven, .estimated).listingVerdict().lines,
["Break even", "(approx.)"])
XCTAssertEqual(result(.baseline, .confirmed).listingVerdict().lines, ["Closest"])
// The Key sheet's legend demonstrates the wording, never a figure.
XCTAssertEqual(
result(.notWorthTheDrive, .confirmed).listingVerdict(showsAmount: false).lines,
["Costs"]
)
// VoiceOver keeps a single line: a drawn break is a spoken pause.
XCTAssertEqual(result(.worthTheDrive, .estimated).listingCapsuleHeadline(),
"Saves £12.50 (approx.)")
}
func testDetailCapsulePlacesApproximationAfterTheFinancialResult() {
func result(_ status: FuelSavingsStatus, _ confidence: JourneyConfidence) -> FuelSavingsResult {
FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: status == .notWorthTheDrive ? 0 : 2.50,
extraDrivingCostGBP: status == .notWorthTheDrive ? 1.20 : 0,
extraDistanceMiles: 2,
netSavingGBP: status == .notWorthTheDrive ? -1.20 : 2.50,
status: status,
journeyConfidence: confidence
)
}
XCTAssertEqual(result(.worthTheDrive, .estimated).detailCapsuleHeadline(), "Save £2.50 (approx.)")
XCTAssertEqual(result(.notWorthTheDrive, .estimated).detailCapsuleHeadline(), "Costs £1.20 more (approx.)")
XCTAssertEqual(result(.breakEven, .estimated).detailCapsuleHeadline(), "Break even (approx.)")
XCTAssertEqual(result(.worthTheDrive, .confirmed).detailCapsuleHeadline(), "Save £2.50")
XCTAssertEqual(result(.notWorthTheDrive, .confirmed).detailCapsuleHeadline(), "Costs £1.20 more")
XCTAssertEqual(result(.breakEven, .confirmed).detailCapsuleHeadline(), "Break even")
XCTAssertEqual(result(.worthTheDrive, .estimated).detailCapsuleSpokenHeadline(), "Save £2.50 (approximately)")
}
private func station(_ id: String, private func station(_ id: String,
price: Double?, price: Double?,
lat: Double = 51.5, lat: Double = 51.5,
@@ -386,8 +450,8 @@ final class FuelSavingsEstimateTests: XCTestCase {
) )
XCTAssertEqual(availability?.result?.status, .baseline) XCTAssertEqual(availability?.result?.status, .baseline)
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest station") XCTAssertEqual(availability?.result?.headline(compact: true), "Closest")
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest station") XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest")
XCTAssertEqual(availability?.result?.netSavingGBP, 0) XCTAssertEqual(availability?.result?.netSavingGBP, 0)
XCTAssertEqual(availability?.result?.extraDistanceMiles, 0) XCTAssertEqual(availability?.result?.extraDistanceMiles, 0)
} }
@@ -439,7 +503,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
/// The exact strings the brief asks for. /// The exact strings the brief asks for.
func testHeadlineWordingMatchesTheBrief() { func testHeadlineWordingMatchesTheBrief() {
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest station") XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest")
XCTAssertEqual(FuelSavingsResult.breakEvenHeadline, "Break even") XCTAssertEqual(FuelSavingsResult.breakEvenHeadline, "Break even")
let saving = FuelSavingsResult(fillLitres: 34.9, let saving = FuelSavingsResult(fillLitres: 34.9,
@@ -530,7 +594,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
} }
/// The baseline capsule has to say what it IS: it previously repeated the /// The baseline capsule has to say what it IS: it previously repeated the
/// status word above it ("Closest station" twice) and never explained that /// status word above it ("Closest" twice) and never explained that
/// the savings are measured against it. /// the savings are measured against it.
func testCapsuleHeadlineMarksTheBaselineAndKeepsTheEstimateMarker() { func testCapsuleHeadlineMarksTheBaselineAndKeepsTheEstimateMarker() {
let baseline = FuelSavingsResult( let baseline = FuelSavingsResult(
@@ -541,7 +605,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
netSavingGBP: 0, netSavingGBP: 0,
status: .baseline status: .baseline
) )
XCTAssertEqual(baseline.capsuleHeadline(), "Closest station") XCTAssertEqual(baseline.capsuleHeadline(), "Closest")
let saving = FuelSavingsResult( let saving = FuelSavingsResult(
fillLitres: 30, fillLitres: 30,
@@ -640,6 +704,29 @@ final class FuelSavingsEstimateTests: XCTestCase {
) )
} }
/// The same figure on its own, for the sheet's row form ("Extra return
/// travel | 6.4 mi"): identical value and identical marker rule, so the row
/// and the caption can never disagree about being an estimate. Below the
/// 0.05-mile floor both forms return nil together.
func testExtraTravelDistanceLabelMatchesTheCaption() {
XCTAssertEqual(
FuelSavingsFormat.extraTravelDistanceLabel(extraDistanceMiles: 6.4, unit: .miles),
"6.4 mi"
)
XCTAssertEqual(
FuelSavingsFormat.extraTravelDistanceLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated),
"Approx. 6.4 mi"
)
XCTAssertEqual(
FuelSavingsFormat.extraTravelDistanceLabel(extraDistanceMiles: 6.4, unit: .kilometers),
"10.3 km"
)
XCTAssertNil(
FuelSavingsFormat.extraTravelDistanceLabel(extraDistanceMiles: 0.04, unit: .miles),
"below the floor there is no extra travel to name"
)
}
/// VoiceOver gets words, not abbreviations: "Approx." → "Approximately" and /// VoiceOver gets words, not abbreviations: "Approx." → "Approximately" and
/// "mi"/"km" → "miles"/"kilometres". /// "mi"/"km" → "miles"/"kilometres".
func testSpokenLabelsSpellOutTheAbbreviations() { func testSpokenLabelsSpellOutTheAbbreviations() {
@@ -209,20 +209,82 @@ final class StationRecommendationBadgeTests: XCTestCase {
) )
} }
/// A near-by station with no saving to speak of is context, not a /// A station with no saving, not the cheapest and not the closest states
/// recommendation — the mockup gives NEAREST no capsule, and nothing else /// nothing a badge could carry.
/// here may invent one. func testNoSavingNotCheapestAndNotClosestMeansNoBadge() {
func testNoSavingAndNotCheapestMeansNoBadge() {
XCTAssertNil( XCTAssertNil(
StationRecommendationBadge.badge(netSavingGBP: 0.0, isCheapest: false, StationRecommendationBadge.badge(netSavingGBP: 0.0, isCheapest: false,
isClosest: false,
bestValueThresholdGBP: threshold) bestValueThresholdGBP: threshold)
) )
XCTAssertNil( XCTAssertNil(
StationRecommendationBadge.badge(netSavingGBP: -2.50, isCheapest: false, StationRecommendationBadge.badge(netSavingGBP: -2.50, isCheapest: false,
isClosest: false,
bestValueThresholdGBP: threshold) bestValueThresholdGBP: threshold)
) )
} }
func testTheClosestStationWearsClosestWhenNothingElseApplies() {
XCTAssertEqual(
StationRecommendationBadge.badge(netSavingGBP: 0.0, isCheapest: false,
isClosest: true,
bestValueThresholdGBP: threshold),
.closest
)
// Closest is a position, so it does not need a savings figure to hold.
XCTAssertEqual(
StationRecommendationBadge.badge(netSavingGBP: nil, isCheapest: false,
isClosest: true,
bestValueThresholdGBP: threshold),
.closest
)
}
/// The nearest forecourt is usually also the cheapest, so without the
/// ordering the Closest label would win by accident and hide the price news.
func testCheapestOutranksClosestWhenBothApply() {
XCTAssertEqual(
StationRecommendationBadge.badge(netSavingGBP: 0.42, isCheapest: true,
isClosest: true,
bestValueThresholdGBP: threshold),
.cheapest
)
}
func testBestValueOutranksBothCheapestAndClosest() {
XCTAssertEqual(
StationRecommendationBadge.badge(netSavingGBP: 3.15, isCheapest: true,
isClosest: true,
bestValueThresholdGBP: threshold),
.bestValue
)
}
/// The whole ladder in one place, so a future case cannot slide in between
/// two rungs unnoticed: Best Value, then Cheapest, then Closest, then none.
func testLabelPrecedenceIsTotalAcrossEveryCombination() {
let cases: [(saving: Double, cheapest: Bool, closest: Bool,
badge: StationRecommendationBadge?)] = [
(3.15, true, true, .bestValue),
(3.15, false, true, .bestValue),
(3.15, false, false, .bestValue),
(0.42, true, true, .cheapest),
(0.42, true, false, .cheapest),
(0.42, false, true, .closest),
(0.42, false, false, nil)
]
for c in cases {
XCTAssertEqual(
StationRecommendationBadge.badge(
netSavingGBP: c.saving, isCheapest: c.cheapest, isClosest: c.closest,
bestValueThresholdGBP: threshold
),
c.badge,
"saving \(c.saving), cheapest \(c.cheapest), closest \(c.closest)"
)
}
}
func testUnavailableSavingsLeaveOnlyTheCheapestBadge() { func testUnavailableSavingsLeaveOnlyTheCheapestBadge() {
XCTAssertEqual( XCTAssertEqual(
StationRecommendationBadge.badge(netSavingGBP: nil, isCheapest: true, StationRecommendationBadge.badge(netSavingGBP: nil, isCheapest: true,
@@ -257,3 +319,217 @@ final class StationRecommendationBadgeTests: XCTestCase {
) )
} }
} }
final class KeySheetContentTests: XCTestCase {
func testPriceRatingsExplainTheImplementedRAGBoundaries() {
XCTAssertEqual(KeySheetContent.priceRatings, [
.init(label: "Good price", description: "Within 1.5p/L of the cheapest."),
.init(label: "Okay", description: "More than 1.5p/L, up to 4p/L above the cheapest."),
.init(label: "Pricey", description: "More than 4p/L above the cheapest.")
])
}
func testSavingsExplainVerdictsWithoutImplyingMinimumSavingQualification() {
XCTAssertEqual(KeySheetContent.savings, [
.init(label: "Saves", description: "You’ll save money compared with your closest station, including travel costs."),
.init(label: "Break even", description: "The saving or extra cost is negligible."),
// "Costs" — the listing capsule's own headline, not "Costs more".
.init(label: "Costs", description: "Filling up here costs more than at your closest station, including travel.")
])
XCTAssertFalse(KeySheetContent.savings.contains { $0.label == "Small saving" })
}
/// Every Key label is sentence case: the capsules are read as phrases, and an
/// all-caps set reads as shouting beside the sentence-case copy around it.
func testEveryKeyLabelIsSentenceCase() {
let labels = (KeySheetContent.priceRatings
+ KeySheetContent.savings
+ KeySheetContent.stationLabels).map(\.label)
for label in labels {
XCTAssertNotEqual(label, label.uppercased(),
"\(label) must be sentence case, not all caps")
}
XCTAssertEqual(KeySheetContent.stationLabels.map(\.label),
["Best value", "Cheapest", "Closest"])
}
/// The Key dispatches its indicators on `StationLabelKind`, so every kind must
/// have exactly one entry in the legend — and each entry's label must be the
/// kind's own, or the row would describe one label and draw another.
func testEveryStationLabelKindHasOneMatchingLegendEntry() {
XCTAssertEqual(StationLabelKind.allCases.count, KeySheetContent.stationLabels.count)
for kind in StationLabelKind.allCases {
let matches = KeySheetContent.stationLabels.filter { $0.label == kind.label }
XCTAssertEqual(matches.count, 1, "\(kind) must own exactly one legend entry")
}
}
/// Recommended symbols, pinned so a rename cannot silently blank a badge.
func testStationLabelKindsUseTheirRecommendedSymbols() {
XCTAssertEqual(StationLabelKind.bestValue.symbol, "medal")
XCTAssertEqual(StationLabelKind.cheapest.symbol, "tag")
XCTAssertEqual(StationLabelKind.closest.symbol, "location")
// No trophy: "Top" is gone. One state, one label.
XCTAssertFalse(StationLabelKind.allCases.map(\.symbol).contains("trophy"))
}
func testStationLabelsDescribeTheCurrentQualificationAndRangeRules() {
XCTAssertEqual(KeySheetContent.stationLabels, [
.init(label: "Best value", description: "Meets your minimum saving after travel costs."),
.init(label: "Cheapest", description: "The lowest fuel price per litre within your selected range."),
.init(label: "Closest", description: "The closest station by journey distance.")
])
}
/// "Top" is retired. It and "Cheapest" were two words for the same flag, and the
/// app drifted between them by device: the phone's rows said Cheapest while the
/// watch said Top for the identical state. Nothing may reintroduce the synonym.
func testTopLabelIsRetired() {
XCTAssertFalse(StationLabelKind.allCases.contains { $0.label == "Top" })
XCTAssertFalse(KeySheetContent.stationLabels.contains { $0.label == "Top" })
// The concept is not lost — it is called Cheapest, which is also the word
// every piece of favourites copy uses.
XCTAssertTrue(StationLabelKind.allCases.contains { $0.label == "Cheapest" })
}
/// The closest label is one word everywhere: the badge, the verdict capsule the
/// baseline station wears, and the Key all read it from `baselineHeadline`.
func testClosestLabelIsTheSameWordEverywhere() {
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest")
XCTAssertEqual(StationLabelKind.closest.label, "Closest")
let baseline = FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 0,
extraDrivingCostGBP: 0,
extraDistanceMiles: 0,
netSavingGBP: 0,
status: .baseline,
journeyConfidence: .confirmed
)
XCTAssertEqual(baseline.headline(), "Closest")
XCTAssertEqual(baseline.headline(), StationLabelKind.closest.label)
}
func testFavouriteAndHelpCopyRemainPinned() {
XCTAssertEqual(
KeySheetContent.favourite,
.init(label: "Favourites", description: "Star a station to save it to your Favourites.")
)
// The link names the calculation, not the feature: the sheet explains the
// fill-up, the return trip and the total, of which the Best Value gate is
// only the last step.
XCTAssertEqual(KeySheetContent.helpTitle, "How does FuelBoard calculate value?")
// The sheet title is a short form of the question, NOT the question itself:
// a nav title clamps to the bar, and the full question truncated to
// "How does FuelBoard…" at both display modes.
XCTAssertEqual(KeySheetContent.helpSheetTitle, "How value is calculated")
XCTAssertLessThan(KeySheetContent.helpSheetTitle.count, KeySheetContent.helpTitle.count)
}
}
/// The More info sheet's ranking capsules. Every rule must come from the shared
/// badge logic — the sheet may show more labels than a row (which shows one), but
/// it must not invent a qualification of its own.
final class StationRankingTests: XCTestCase {
private let threshold = 2.50
func testNoQualificationProducesNoCapsules() {
XCTAssertEqual(
StationRanking.kinds(netSavingGBP: 0.40, isCheapestInRange: false,
bestValueThresholdGBP: threshold),
[]
)
}
func testBestValueUsesTheSameGateAsTheListingBadge() {
// Exactly at the threshold qualifies, matching StationRecommendationBadge.
XCTAssertEqual(
StationRanking.kinds(netSavingGBP: threshold, isCheapestInRange: false,
bestValueThresholdGBP: threshold),
[.bestValue]
)
// A hair under does not.
XCTAssertEqual(
StationRanking.kinds(netSavingGBP: threshold - 0.01, isCheapestInRange: false,
bestValueThresholdGBP: threshold),
[]
)
}
/// Cheapest is the only ranking label besides Best value. Whichever tab opened
/// the sheet — Stations or Favourites — it means the lowest price among the
/// stations on that screen, so there is no second cheapest-flavoured capsule.
func testCheapestIsTheOnlyCheapestFlavouredLabel() {
XCTAssertEqual(
StationRanking.kinds(netSavingGBP: nil, isCheapestInRange: true,
bestValueThresholdGBP: threshold),
[.cheapest]
)
XCTAssertEqual(
StationRanking.kinds(netSavingGBP: nil, isCheapestInRange: false,
bestValueThresholdGBP: threshold),
[]
)
}
func testCapsulesAreOrderedBestValueThenCheapest() {
XCTAssertEqual(
StationRanking.kinds(netSavingGBP: 9.99, isCheapestInRange: true,
bestValueThresholdGBP: threshold),
[.bestValue, .cheapest]
)
}
/// The sheet has room for every qualification, so a station holding all three
/// shows all three — in the Key's order, which is also the row's precedence,
/// so the sheet reads left-to-right the same way a row resolves top-to-bottom.
func testCapsulesAreOrderedBestValueCheapestClosest() {
XCTAssertEqual(
StationRanking.kinds(netSavingGBP: 9.99, isCheapestInRange: true,
isClosestInRange: true, bestValueThresholdGBP: threshold),
[.bestValue, .cheapest, .closest]
)
}
/// The closest station with no saving and no price advantage still earns its
/// label on the sheet — the one case a listing row cannot show, because there
/// Best Value and Cheapest are both unavailable and Closest wins the slot.
func testClosestAloneEarnsOneCapsule() {
XCTAssertEqual(
StationRanking.kinds(netSavingGBP: nil, isCheapestInRange: false,
isClosestInRange: true, bestValueThresholdGBP: threshold),
[.closest]
)
XCTAssertEqual(
StationRanking.kinds(netSavingGBP: 0.0, isCheapestInRange: false,
isClosestInRange: true, bestValueThresholdGBP: threshold),
[.closest]
)
}
/// Closest must not sneak in when the host did not flag it — the sheet never
/// invents a rule of its own.
func testNoClosestFlagMeansNoClosestCapsule() {
XCTAssertEqual(
StationRanking.kinds(netSavingGBP: nil, isCheapestInRange: true,
isClosestInRange: false, bestValueThresholdGBP: threshold),
[.cheapest]
)
}
/// A row shows ONE badge; the sheet may show several. Both must agree on the
/// Best Value decision, or the sheet would claim a label the list behind it
/// withheld.
func testSheetAgreesWithTheRowOnBestValue() {
for netSaving in [-3.0, 0.0, 2.49, 2.50, 12.0, .nan] as [Double] {
let rowSaysBestValue = StationRecommendationBadge.badge(
netSavingGBP: netSaving, isCheapest: false, bestValueThresholdGBP: threshold
) == .bestValue
let sheetSaysBestValue = StationRanking.kinds(
netSavingGBP: netSaving, isCheapestInRange: false,
bestValueThresholdGBP: threshold
).contains(.bestValue)
XCTAssertEqual(rowSaysBestValue, sheetSaysBestValue, "disagreed on net saving \(netSaving)")
}
}
}
@@ -31,3 +31,12 @@ final class FuelStoreBestValueThresholdTests: XCTestCase {
} }
} }
} }
final class FuelStoreAlertModeTests: XCTestCase {
func testLegacyBestValueSelectionLoadsAsCheapest() {
FuelStore.saveAlertsMode(.bestValue)
defer { FuelStore.saveAlertsMode(.cheapest) }
XCTAssertEqual(FuelStore.loadAlertsMode(), .cheapest)
}
}
+6 -2
View File
@@ -516,8 +516,12 @@ struct ContentView: View {
.fontWeight(favourite.isCheapest ? .semibold : .regular) .fontWeight(favourite.isCheapest ? .semibold : .regular)
.lineLimit(1) .lineLimit(1)
if favourite.isCheapest { if favourite.isCheapest {
Text("TOP") // The same WORD as every other surface, not just the same
.font(.system(size: 9, weight: .bold)) // capitalisation. The phone's rows call this state
// "Cheapest"; a watch that said "Top" made one state look
// like two different achievements.
Text("Cheapest")
.font(.system(size: 9, weight: .semibold))
.padding(.horizontal, 4) .padding(.horizontal, 4)
.padding(.vertical, 2) .padding(.vertical, 2)
.background(fuel.color.opacity(0.18), in: Capsule()) .background(fuel.color.opacity(0.18), in: Capsule())
+2 -2
View File
@@ -16,9 +16,9 @@ enum DataRefreshMode: String, CaseIterable, Identifiable {
var summary: String { var summary: String {
switch self { switch self {
case .standard: case .standard:
return "Saves battery. Refreshes price data up to twice daily." return "Saves battery by checking for new prices up to twice a day."
case .smart: case .smart:
return "Checks for newer data more often in the background and refreshes full prices only when an update is available." return "Checks for new prices more often, keeping your data fresher."
} }
} }
} }
+255 -10
View File
@@ -45,7 +45,7 @@ enum FuelSavingsStatus: String, Codable, Equatable {
case breakEven case breakEven
case notWorthTheDrive case notWorthTheDrive
/// The comparison baseline itself — the station every other saving is /// The comparison baseline itself — the station every other saving is
/// measured against. Shown as "Closest station", never "Break even". /// measured against. Shown as "Closest", never "Break even".
case baseline case baseline
var displayName: String { var displayName: String {
@@ -136,11 +136,25 @@ enum FuelSavingsFormat {
static func extraTravelLabel(extraDistanceMiles: Double, static func extraTravelLabel(extraDistanceMiles: Double,
unit: DistanceUnit, unit: DistanceUnit,
confidence: JourneyConfidence = .confirmed) -> String? { confidence: JourneyConfidence = .confirmed) -> String? {
guard let distanceText = extraTravelDistanceLabel(extraDistanceMiles: extraDistanceMiles,
unit: unit,
confidence: confidence) else { return nil }
return "\(distanceText) extra return travel"
}
/// The extra-travel FIGURE on its own, with its confidence marker: "4.3 mi"
/// or "Approx. 4.3 mi".
///
/// A row form ("Extra return travel | 4.3 mi") names the journey in its own
/// label, so this is the same figure and the same "Approx." rule as
/// `extraTravelLabel`, from the same inputs — never rebuilt at a call site,
/// or one surface marks an estimate while another drops the marker.
static func extraTravelDistanceLabel(extraDistanceMiles: Double,
unit: DistanceUnit,
confidence: JourneyConfidence = .confirmed) -> String? {
guard extraDistanceMiles > 0.05 else { return nil } guard extraDistanceMiles > 0.05 else { return nil }
let distanceText = distance(extraDistanceMiles, unit: unit) let distanceText = distance(extraDistanceMiles, unit: unit)
return confidence == .estimated return confidence == .estimated ? "Approx. \(distanceText)" : distanceText
? "Approx. \(distanceText) extra return travel"
: "\(distanceText) extra return travel"
} }
/// Compact comparison copy kept outside the coloured verdict capsule. /// Compact comparison copy kept outside the coloured verdict capsule.
@@ -173,8 +187,13 @@ enum FuelSavingsFormat {
} }
extension FuelSavingsResult { extension FuelSavingsResult {
/// Baseline row label — required wording. /// The baseline's capsule label.
static let baselineHeadline = "Closest station" ///
/// "Closest", not "Closest station": it is a station LABEL sitting alongside
/// "Best value" and "Cheapest", and the noun is implied by the row it is on.
/// This is also the ONE source for the word, so the closest station's badge and
/// its verdict capsule cannot word it differently.
static let baselineHeadline = "Closest"
/// Neutral result label — required wording. /// Neutral result label — required wording.
static let breakEvenHeadline = "Break even" static let breakEvenHeadline = "Break even"
@@ -195,7 +214,7 @@ extension FuelSavingsResult {
} }
/// Row headline, e.g. "£1.22 better off after travel", "£0.31 worse off after /// Row headline, e.g. "£1.22 better off after travel", "£0.31 worse off after
/// travel", "Break even", "Closest station". /// travel", "Break even", "Closest".
/// ///
/// Estimated comparisons are prefixed "Approx. " for positive/negative money /// Estimated comparisons are prefixed "Approx. " for positive/negative money
/// results — the WORD, never a "~": a tilde reads as decoration (and assistive /// results — the WORD, never a "~": a tilde reads as decoration (and assistive
@@ -222,12 +241,100 @@ extension FuelSavingsResult {
headline().replacingOccurrences(of: "Approx. ", with: "Approximately ") headline().replacingOccurrences(of: "Approx. ", with: "Approximately ")
} }
/// Full monetary verdict used by Station Details. Estimated confidence is a
/// trailing qualifier so the amount remains the first figure users scan.
func detailCapsuleHeadline() -> String {
let qualifier = journeyConfidence.isEstimated ? " (approx.)" : ""
switch status {
case .baseline:
return Self.baselineHeadline
case .breakEven:
return "\(Self.breakEvenHeadline)\(qualifier)"
case .worthTheDrive:
return "Save \(FuelSavingsFormat.money(netSavingGBP))\(qualifier)"
case .notWorthTheDrive:
return "Costs \(FuelSavingsFormat.money(abs(netSavingGBP))) more\(qualifier)"
}
}
func detailCapsuleSpokenHeadline() -> String {
detailCapsuleHeadline().replacingOccurrences(of: "(approx.)", with: "(approximately)")
}
/// Row capsule copy. The baseline relationship is shown as secondary text /// Row capsule copy. The baseline relationship is shown as secondary text
/// beside the capsule in the station listing and detail summary, so the /// beside the capsule in the station listing and detail summary, so the
/// capsule itself contains only the verdict. /// capsule itself contains only the verdict.
func capsuleHeadline() -> String { func capsuleHeadline() -> String {
headline(compact: true) headline(compact: true)
} }
/// The station-listing capsule's verdict, split into the lines the capsule
/// draws.
///
/// The amount always takes its OWN line, so where the capsule breaks is
/// decided by the data, never by the width it was handed: the figure can
/// never straddle two lines, and a taller verdict grows the capsule downward
/// instead of widening the row's fixed price column.
struct ListingVerdict: Equatable {
/// "Saves", "Costs", "Break even" or "Closest".
let headline: String
/// "£3.10" / "£1.20 more" — nil when there is no figure to show.
let amount: String?
/// "(approx.)" when the journey behind the figure was estimated.
let qualifier: String?
var lines: [String] { [headline, amount, qualifier].compactMap { $0 } }
/// Single-line form, for VoiceOver and tests. A drawn newline is a pause
/// in the middle of a phrase when spoken.
var spokenText: String { lines.joined(separator: " ") }
}
/// Verdict for the station listing rows — Stations and Favourites share one
/// row, so they share this. `showsAmount: false` is the Key sheet's legend,
/// which demonstrates the wording without inventing a figure.
func listingVerdict(showsAmount: Bool = true) -> ListingVerdict {
let qualifier = journeyConfidence.isEstimated ? String(localized: "(approx.)") : nil
switch status {
case .worthTheDrive:
return ListingVerdict(
headline: String(localized: "Saves"),
amount: showsAmount ? FuelSavingsFormat.money(netSavingGBP) : nil,
qualifier: qualifier
)
case .notWorthTheDrive:
return ListingVerdict(
headline: String(localized: "Costs"),
amount: showsAmount ? Self.amountWithMore(abs(netSavingGBP)) : nil,
qualifier: qualifier
)
case .breakEven:
// No figure: ±£0.05 is noise, so this stays a single line unless the
// estimate qualifier adds its own.
return ListingVerdict(
headline: String(localized: "Break even"),
amount: nil,
qualifier: qualifier
)
case .baseline:
return ListingVerdict(
headline: Self.baselineHeadline,
amount: nil,
qualifier: nil
)
}
}
/// "£1.20 more" — kept as a format string so the copy editor owns the word.
private static func amountWithMore(_ value: Double) -> String {
String(format: String(localized: "%@ more"), FuelSavingsFormat.money(value))
}
/// One-line verdict for voice and tests; the capsule itself renders
/// `listingVerdict()`'s lines separately.
func listingCapsuleHeadline() -> String {
listingVerdict().spokenText
}
} }
/// One station's journey distance, plus how it was obtained. /// One station's journey distance, plus how it was obtained.
@@ -262,26 +369,164 @@ struct FuelSavingsRankedStation: Equatable {
let pricePencePerLitre: Double let pricePencePerLitre: Double
} }
/// The user-facing copy shown by the Stations tab's Key sheet. Keeping the
/// descriptions in one Foundation-only model lets tests pin the exact meaning of
/// each visual indicator without coupling the wording to SwiftUI layout.
///
/// Every label is SENTENCE CASE ("Best value", not "BEST VALUE"): the labels
/// are read as phrases, and a capsule set in all caps reads as shouting beside
/// the sentence-case copy in the rows around it. The tests pin these exact
/// strings, and `StationLabelKind` maps each one to its indicator, so the view
/// never matches on the literal text.
enum KeySheetContent {
struct Item: Equatable {
let label: String
let description: String
}
static let priceRatings = [
Item(label: "Good price", description: "Within 1.5p/L of the cheapest."),
Item(label: "Okay", description: "More than 1.5p/L, up to 4p/L above the cheapest."),
Item(label: "Pricey", description: "More than 4p/L above the cheapest.")
]
/// The same three verdicts the listing capsule draws, so the legend and the
/// rows cannot disagree on a word. "Costs" is the capsule's own headline.
static let savings = [
Item(label: "Saves", description: "You’ll save money compared with your closest station, including travel costs."),
Item(label: "Break even", description: "The saving or extra cost is negligible."),
Item(label: "Costs", description: "Filling up here costs more than at your closest station, including travel.")
]
/// The station labels, in the order the Key lists them.
///
/// There is no "Top" entry. Top and Cheapest were two words for one state —
/// both driven by the same "cheapest among the stations you are looking at"
/// flag, differing only in whether the candidate set was the radius or the
/// Favourites — and which word a surface used drifted by device. Cheapest is
/// the survivor: it says what it measures, and it is already the word every
/// piece of favourites copy uses ("your cheapest E10 favourite", "Cheapest
/// favourite" alert). The concept survives; the synonym does not.
static let stationLabels = [
Item(label: "Best value", description: "Meets your minimum saving after travel costs."),
Item(label: "Cheapest", description: "The lowest fuel price per litre within your selected range."),
Item(label: "Closest", description: "The closest station by journey distance.")
]
static let favourite = Item(
label: "Favourites",
description: "Star a station to save it to your Favourites."
)
/// The link that opens the explanation sheet. Names the question the sheet
/// answers rather than the feature ("How does Best Value work?"), because the
/// sheet explains the whole calculation — fill-up, return trip, extra travel,
/// total — not only the Best Value gate at the end of it.
static let helpTitle = "How does FuelBoard calculate value?"
/// The explanation sheet's own title: a SHORT form of the same question, not
/// the link's words. A navigation title is clamped to the bar, and the full
/// question truncated to "How does FuelBoard…" in BOTH inline and large
/// display modes — so the row carries the full question and the sheet it opens
/// names the same topic in words that render in full.
static let helpSheetTitle = "How value is calculated"
}
/// A station label's meaning, independent of its copy.
///
/// The Key sheet used to dispatch its indicator on the label's literal text
/// (`case "BEST VALUE":`), so renaming a label silently dropped that row to the
/// fallback indicator. The enum makes the mapping explicit and lets the
/// compiler catch a missing case.
enum StationLabelKind: String, CaseIterable {
case bestValue
case cheapest
case closest
/// Matches the corresponding `KeySheetContent.stationLabels` entry.
var label: String {
switch self {
case .bestValue: return String(localized: "Best value")
case .cheapest: return String(localized: "Cheapest")
// "Closest", from the one shared constant, so the badge, the verdict
// capsule and the Key cannot word the same label three ways.
case .closest: return FuelSavingsResult.baselineHeadline
}
}
var symbol: String {
switch self {
case .bestValue: return "medal"
case .cheapest: return "tag"
case .closest: return "location"
}
}
}
/// Which recommendation badge a station listing row shows, if any. /// Which recommendation badge a station listing row shows, if any.
/// ///
/// Presentation only — no new rules: the BEST VALUE qualification reuses the /// Presentation only — no new rules: the BEST VALUE qualification reuses the
/// app's existing Best Value gate (the user's alert threshold, the same figure /// app's existing Best Value gate (the user's alert threshold, the same figure
/// `FuelSavingsCalculator.bestValueAlertCandidate` is given) and CHEAPEST /// `FuelSavingsCalculator.bestValueAlertCandidate` is given) and CHEAPEST
/// reuses the caller's existing "cheapest in range" flag (the station behind /// reuses the caller's existing "cheapest in range" flag (the station behind
/// `topStationID`). One badge per row: a station that is both the cheapest and /// `cheapestStationID`). One badge per row: a station that is both the cheapest
/// worth the drive shows BEST VALUE, the stronger of the two. /// and worth the drive shows BEST VALUE, the stronger of the two.
enum StationRecommendationBadge: Equatable { enum StationRecommendationBadge: Equatable {
case bestValue case bestValue
case cheapest case cheapest
case closest
/// One label per row, in this order: Best Value, then Cheapest, then Closest.
///
/// A station can hold all three at once — the nearest forecourt is often also
/// the cheapest — and a row has room for one, so the order is decided here
/// rather than left to each caller. Being nearby is the weakest claim: a short
/// drive says nothing about the price, and unlike the other two it reports no
/// saving at all. Closest therefore surfaces only when the row earns neither
/// of the others.
static func badge(netSavingGBP: Double?, static func badge(netSavingGBP: Double?,
isCheapest: Bool, isCheapest: Bool,
isClosest: Bool = false,
bestValueThresholdGBP: Double) -> StationRecommendationBadge? { bestValueThresholdGBP: Double) -> StationRecommendationBadge? {
if let netSavingGBP, netSavingGBP.isFinite, bestValueThresholdGBP.isFinite, if let netSavingGBP, netSavingGBP.isFinite, bestValueThresholdGBP.isFinite,
netSavingGBP >= bestValueThresholdGBP { netSavingGBP >= bestValueThresholdGBP {
return .bestValue return .bestValue
} }
return isCheapest ? .cheapest : nil if isCheapest { return .cheapest }
return isClosest ? .closest : nil
}
}
/// Every ranking label a station qualifies for, in display order.
///
/// A listing row shows ONE badge (Best Value outranks Cheapest outranks Closest,
/// because a row has room for one). The More info sheet has room for all of them,
/// so it shows each qualification side by side — but it must never invent a rule
/// of its own, so this defers to `StationRecommendationBadge.badge` for the Best
/// Value gate and takes Cheapest and Closest as the SAME flags the host list
/// gives its rows.
///
/// There is no Top label any more: both tabs mean the same thing by "cheapest" —
/// the lowest price among the stations on screen, whether that set is the radius or
/// the Favourites — so a host passes one flag and the sheet renders one capsule.
enum StationRanking {
static func kinds(netSavingGBP: Double?,
isCheapestInRange: Bool,
isClosestInRange: Bool = false,
bestValueThresholdGBP: Double) -> [StationLabelKind] {
var kinds: [StationLabelKind] = []
// The same gate the listing row uses, asked without a cheapest flag so the
// ranking is decided only by the saving.
if StationRecommendationBadge.badge(
netSavingGBP: netSavingGBP,
isCheapest: false,
isClosest: false,
bestValueThresholdGBP: bestValueThresholdGBP
) == .bestValue {
kinds.append(.bestValue)
}
if isCheapestInRange { kinds.append(.cheapest) }
if isClosestInRange { kinds.append(.closest) }
return kinds
} }
} }
+61 -16
View File
@@ -236,7 +236,7 @@ enum PriceDisplayStyle: String, Codable, CaseIterable, Identifiable {
var displayName: String { var displayName: String {
switch self { switch self {
case .stationSign: return "Station sign" case .stationSign: return "Pence per litre"
case .poundsPence: return "Pounds & pence" case .poundsPence: return "Pounds & pence"
} }
} }
@@ -245,8 +245,8 @@ enum PriceDisplayStyle: String, Codable, CaseIterable, Identifiable {
// MARK: - Station tap action // MARK: - Station tap action
/// What happens when a station row is tapped in the Stations/Favourites tabs: /// What happens when a station row is tapped in the Stations/Favourites tabs:
/// open Apple Maps directions immediately (historic behaviour, default), or /// open Apple Maps directions immediately, or show a detail sheet (name,
/// show a detail sheet (name, address, distance, Directions button). /// address, distance, Directions button) — the default.
enum StationTapAction: String, Codable, CaseIterable, Identifiable { enum StationTapAction: String, Codable, CaseIterable, Identifiable {
case openMap // tap → Apple Maps directions straight away case openMap // tap → Apple Maps directions straight away
case showDetails // tap → detail sheet, Directions button inside case showDetails // tap → detail sheet, Directions button inside
@@ -255,8 +255,8 @@ enum StationTapAction: String, Codable, CaseIterable, Identifiable {
var displayName: String { var displayName: String {
switch self { switch self {
case .openMap: return "Open map" case .openMap: return "Directions"
case .showDetails: return "More info" case .showDetails: return "Station details"
} }
} }
} }
@@ -701,11 +701,13 @@ struct FuelStore {
} }
static func loadAlertsMode() -> AlertMode { static func loadAlertsMode() -> AlertMode {
if let raw = loadString(service: alertsModeKey), let mode = AlertMode(rawValue: raw) { guard let raw = loadString(service: alertsModeKey),
return mode let mode = AlertMode(rawValue: raw),
} mode == .cheapest else {
return .cheapest return .cheapest
} }
return mode
}
static func saveAlertsMode(_ mode: AlertMode) { static func saveAlertsMode(_ mode: AlertMode) {
saveString(mode.rawValue, service: alertsModeKey) saveString(mode.rawValue, service: alertsModeKey)
@@ -799,8 +801,9 @@ struct FuelStore {
saveString(style.rawValue, service: priceDisplayStyleKey) saveString(style.rawValue, service: priceDisplayStyleKey)
} }
// MARK: Station tap action — open map vs detail sheet. Stored raw value; // MARK: Station tap action — detail sheet vs open map. Stored raw value;
// default open map preserves the historic tap behaviour for existing installs. // the default is the detail sheet, so a tap always lands somewhere the app
// can come back from. Directions still one tap away, inside the sheet.
static let stationTapActionKey = "fuelboard.stationTapAction" static let stationTapActionKey = "fuelboard.stationTapAction"
@@ -808,7 +811,7 @@ struct FuelStore {
if let raw = loadString(service: stationTapActionKey), let action = StationTapAction(rawValue: raw) { if let raw = loadString(service: stationTapActionKey), let action = StationTapAction(rawValue: raw) {
return action return action
} }
return .openMap return .showDetails
} }
static func saveStationTapAction(_ action: StationTapAction) { static func saveStationTapAction(_ action: StationTapAction) {
@@ -850,6 +853,26 @@ struct FuelStore {
saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey) saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey)
} }
/// One field of the Fuel Savings settings. A control that edits a single
/// value saves just that field, so moving the MPG slider rewrites the MPG key
/// instead of all four — four UserDefaults writes and four keychain round
/// trips per gesture is what made those sliders stutter.
enum FuelSavingsField {
case enabled
case mpg
case amount
case unit
}
static func saveFuelSavingsField(_ field: FuelSavingsField, from settings: FuelSavingsSettings) {
switch field {
case .enabled: saveString(settings.enabled ? "1" : "0", service: fuelSavingsEnabledKey)
case .mpg: saveString(String(settings.mpg), service: fuelSavingsMPGKey)
case .amount: saveString(String(settings.preferredAmount.value), service: fuelSavingsAmountKey)
case .unit: saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey)
}
}
/// Superscript digit glyphs for the pounds & pence format's small raised /// Superscript digit glyphs for the pounds & pence format's small raised
/// third digit (the forecourt style: "£1.29⁹"). /// third digit (the forecourt style: "£1.29⁹").
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"] private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
@@ -1719,11 +1742,18 @@ struct FuelStore {
} }
private static func writeKeychain(data: Data, service: String) { private static func writeKeychain(data: Data, service: String) {
let deleteQuery: [String: Any] = [ // Update in place when the item already exists. Delete-then-add cost two
// round trips to securityd and briefly left the key holding no value at
// all, which is half of why a setting could disagree between launches.
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service, kSecAttrService as String: service,
] ]
SecItemDelete(deleteQuery as CFDictionary) let status = SecItemUpdate(query as CFDictionary,
[kSecValueData as String: data] as CFDictionary)
guard status == errSecItemNotFound else { return }
// Nothing stored under this service yet — the first write after the app
// was deleted, or a key added since. Add it.
let addQuery: [String: Any] = [ let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service, kSecAttrService as String: service,
@@ -1806,9 +1836,24 @@ func label(unit: DistanceUnit) -> String {
/// Row/sheet label that keeps the one-way station distance primary, then /// Row/sheet label that keeps the one-way station distance primary, then
/// adds the independently measured or estimated out-and-back distance. /// adds the independently measured or estimated out-and-back distance.
func labelWithReturn(unit: DistanceUnit) -> String { func labelWithReturn(unit: DistanceUnit) -> String {
let returnText = unit.format(returnKM) "\(label(unit: unit)) (\(returnLabel(unit: unit)))"
let returnLabel = returnIsApproximate ? "Approx. \(returnText)" : returnText }
return "\(label(unit: unit)) (\(returnLabel) return)"
/// The out-and-back figure on its own: "5.0 mi return" / "Approx. 5.0 mi return".
///
/// Split out of `labelWithReturn` for surfaces that print the one-way and the
/// return figure on SEPARATE lines (the More info sheet's distance column). Both
/// figures still come from this one type, so a routed value can never be marked
/// "Approx." while its return figure is not — or the other way round.
func returnLabel(unit: DistanceUnit) -> String {
let text = unit.format(returnKM)
return returnIsApproximate ? "Approx. \(text) return" : "\(text) return"
}
/// VoiceOver form of `returnLabel(unit:)`.
func spokenReturnLabel(unit: DistanceUnit) -> String {
let text = unit.spoken(returnKM)
return returnIsApproximate ? "Approximately \(text) return" : "\(text) return"
} }
/// VoiceOver form of `label(unit:)`. /// VoiceOver form of `label(unit:)`.