Compare commits

...
Author SHA1 Message Date
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
FuelBoard Contributor ff8a1e18d0 Redesign the station listing and move the Best Value controls
Two columns split by a 1pt hairline: identity on the left at a fixed 52%
share so rows line up when scrolling, price and savings on the right,
leading-aligned on one axis with the price dominant. The favourites star
sits under the station logo, freeing the width the pounds-and-pence price
needs to render in full rather than scaled or truncated.

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

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

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

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

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

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

- More info shows one row, "Price updated 28h ago" (or "Not provided"),
  replacing the separate station-report and fuel-update-time rows and the
  blanket "price freshness is not confirmed" label.
- Rows and the detail sheet stay quiet when a saving is shown; only an
  ineligible comparison explains itself. Provisional savings already read
  as estimates from the capsule, so the fuel-named warning lines are gone.
- Freshness/eligibility logic is unchanged: it still gates the baseline and
  still supports grade-specific timestamps for when the relay emits them.
- Tests updated; localisation keys for the removed copy dropped.
2026-10-15 22:00:48 +01:00
FuelBoard Contributor 5c0458dbf6 Drop deprecated MapKit and ActivityKit calls, and a dead capture
- RoadDistanceService builds route endpoints with MKMapItem(location:address:)
  instead of the MKPlacemark path deprecated in iOS 26.
- LiveActivityManager updates through ActivityContent, replacing the
  ActivityKit update(using:) call deprecated in iOS 16.2.
- The location-update hook no longer captures an unused state object; the
  closure body already runs on self, so behaviour is unchanged.
2026-10-15 21:37:41 +01:00
FuelBoard Contributor 9e77ee2947 Best Value: explain price age per surface instead of one blanket warning
- Station detail now separates the station's own report age from the
  selected fuel's update time, so a recent station-wide report no longer
  reads as an old price.
- Warning copy names the fuel and says which side is weak: the station's
  price is not individually dated, or the baseline's is. The misleading
  "unverified or older than 7 days" line is gone.
- Wording stays "Closest" throughout; new copy uses Unleaded / Premium /
  Diesel rather than the internal grade codes.
- Localisation: new and updated keys for the added copy.
2026-10-15 21:37:35 +01:00
FuelBoard Contributor 7c2eeb2604 Best Value: baseline on the closest eligible price and gate on freshness
- Add FuelPriceFreshness: selected-fuel-aware eligibility plus a shared
  comparison confidence, so a saving is presented as verified only when
  both the candidate and the baseline pass the gate for the fuel on screen.
- Rank against the closest station with an eligible selected-fuel price
  instead of the physically closest station, so a single stale neighbour
  can no longer invalidate every comparison in the list. Ordinary Closest
  sorting still includes every priced station.
- Keep ineligible prices visible, fall back to pump-price order, and label
  Best Value unavailable when no comparison qualifies.
- Widget: same baseline and confidence path as the app.
- Tests: stale-closest baseline, alert baseline, eligibility policy,
  boundary and missing-timestamp regressions.
2026-10-15 21:37:24 +01:00
FuelBoard Contributor 39a97f4905 Standardise onboarding header sizes 2026-10-15 17:23:25 +01:00
FuelBoard Contributor 3819b8a6ab Add Best Value small widget and standardise Closest wording 2026-10-15 17:14:02 +01:00
FuelBoard Contributor a5688cbac2 Clarify fuel savings comparison labels 2026-10-15 11:38:24 +01:00
FuelBoard Contributor 558f956d8d Update FuelBoard onboarding copy, title formatting, key sheet card wrapping, and best-value button placement 2026-10-14 16:22:31 +01:00
FuelBoard Contributor f04a0fe0f5 Key sheet: gate the How Best Value row on the feature, like the More info row
The More info row went inside the Best Value card's `if let fuelSavings`, but the
Key sheet row was left ungated, so it stayed visible with Best Value switched off.
It is now gated on `fuelSavingsSettings.isConfigured` — exactly the condition
`savingsForStation` uses, i.e. the same gate as the card — so the two rows appear
and disappear together.

Verified on the simulator by toggling Best Value in Settings and scrolling the Key
sheet to its end (four drags, sheet still up and its footer in view each time):
- OFF: the row is absent at every scroll position, and the More info sheet shows
  neither the card nor the row;
- ON: the row is present, and the More info card and its row are both back.
210 tests pass; Release builds.
2026-10-14 15:19:20 +01:00
FuelBoard Contributor f35f605b58 Onboarding welcome: the app icon stands on its own, with no circle behind it
The first onboarding screen showed the app icon inside AnimatedOnboardingIcon's
soft circle — a 10% tinted disc with an 18% ring around it. The wrapper now takes
`showsHalo`, defaulting to true so the other five pages and BestValueIntroView keep
theirs unchanged; the welcome page passes false.

Verified with before/after installs on the simulator:
- the icon's artwork is pixel-identical and unmoved (centre 603,661, 95 pt);
- the halo is gone — the tile's non-background footprint falls from the 118 pt
  ring to exactly the 96 pt tile, and the ring pixels at r=59 pt lose their tint;
- page 1 differs only by the status-bar clock, so the other pages are untouched.

The copy below the icon tightens by ~11 pt: the page block is centred between two
spacers and the icon's container no longer contributes 22 pt of height. The page
dots and the Continue button do not move. The icon keeps its soft accent shadow.
2026-10-14 15:02:29 +01:00
FuelBoard Contributor 7da277547b More info sheet: the copy confirmation reads "Postcode copied"
The capsule label was just "Copied", which reads oddly above a row whose hint is
"Copies the postcode". It is now "Postcode copied".

Still centred over the address row (centre x 603 of a 1206 px screen) and still a
pure overlay: the bands above and below the capsule are pixel-identical while it
is up (no reflow), and the row stays tappable straight through it.

The "Copied" localisation key goes with its only use; "Postcode copied" replaces it.
2026-10-14 14:52:07 +01:00
FuelBoard Contributor 93df94ddc9 How Best Value works: its own sheet, opened from the Key sheet and More info sheet
The explanation used to be a section inside the Key sheet. It is a standalone
HowBestValueSheet now, opened from a tappable row with a chevron: at the bottom of
the Key sheet, and at the bottom of the More info sheet below the Best Value card.

Gating: the More info row sits INSIDE the same `if let fuelSavings` as the card it
follows, so it disappears with the feature. Verified with Best Value toggled off in
Settings: the More info sheet then shows neither the card nor the row. The Key
sheet row is deliberately ungated — that sheet is the legend and is reachable
whatever the feature is doing.

Copy is unchanged: the same Localizable.strings strings are reused, and the old
section header became the sheet's title. A DEBUG-only `-showBestValueInfo` hook
opens the sheet without a tap.
2026-10-14 14:37:18 +01:00
FuelBoard Contributor 78b43ce6d7 Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 13:57:21 +01:00
FuelBoard Contributor 7dbb3d1a15 More info sheet: float the "Copied" capsule over the address row, not the map
The confirmation belongs to the address row, so hovering it over the map read as
belonging to the map. It now overlays the address row itself, horizontally centred
on the address line, as .overlay(alignment: .center) on the row with
allowsHitTesting(false) still keeping the row tappable through the capsule.

Verified: with the capsule up, the OCR box sits at y 1189-1231 inside the address
band (map bottom is 1075), the tint blob is centred at x 602.5 against a screen
centre of 603, the row's ink runs outside the capsule footprint are byte-identical
to the idle frame, and a real tap on the capsule's own footprint still copies.
2026-10-14 13:57:19 +01:00
FuelBoard Contributor 13967c3782 Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 13:45:05 +01:00
FuelBoard Contributor 6e534ed5b7 More info sheet: float the "Copied" capsule and put the copy glyph on the address line
The address row is back to a single line: the copy glyph now sits on the address
line itself (trailing, plain glyph — the confirmation says it elsewhere), and the
always-reserved second line is gone.

The "Copied" confirmation is now a green-tinted capsule that floats over the
bottom of the map, directly above the address and horizontally centred. It is an
overlay with allowsHitTesting(false), so nothing reflows while it is up (verified:
the address row's ink, column for column, does not move) and the map's tap target
stays live underneath (verified: tapping where the capsule floats still opens
Apple Maps). A material base keeps it legible against map tiles, where a 14% wash
alone — the notice-capsule recipe — disappears. Timing is unchanged: 2s, then
fades, with the success haptic on copy.
2026-10-14 13:45:05 +01:00
FuelBoard Contributor 4718e9b76a Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 13:16:20 +01:00
FuelBoard Contributor 73db893f4f More info sheet: drop the hairline between the map and the address
The map already reads as its own block, so the row separator below it only
added clutter. Removed with .listRowSeparator(.hidden, edges: .bottom) on the map
row, leaving the Address/Distance and Distance/actions hairlines in place.
2026-10-14 13:16:19 +01:00
FuelBoard Contributor 6dfe732199 Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 12:48:33 +01:00
FuelBoard Contributor 7da09928ca More info sheet: one card for the map, address, distance and the two actions
The map, the address, the distance, any notices and the Directions/Report issue
row now share a single section, in that order, with the buttons as the last row.
The list's own row hairlines separate them (inset like Address/Distance), so no
hand-rolled Divider is added — that would double the line.

The map loses .listRowInsets(EdgeInsets()) so it sits inset inside the card with
its rounded corners, aligned with the hairlines and text rather than butting the
card edges.

Directions/Report issue keep .buttonStyle(.borderless); verified with real
simulated taps in the merged card that each fires alone.
2026-10-14 12:48:33 +01:00
FuelBoard Contributor f0c13a79d6 Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 12:25:17 +01:00
FuelBoard Contributor 666d59c483 More info sheet: give each action button its own tap target
Directions and Report issue both fired on any tap inside their row: with the
default button style a List row has a single row-wide tap target, so tapping
one button triggered the other's action too (tapping Report issue opened Apple
Maps, and tapping Directions showed the report alert).

`.buttonStyle(.borderless)` gives each button its own hit area over its
half-row frame. Verified with real simulated taps on both buttons.
2026-10-14 12:25:16 +01:00
FuelBoard Contributor 311118f0fa Add a DEBUG screen-marker hook so Simulator taps can be verified
`-qaScreenMarker` paints the screen flat magenta, which lets a macOS capture
locate the device-screen rectangle exactly and map simulator-screenshot pixels
to click coordinates. `simctl` cannot drive taps and the project has no UI-test
target, so this is the only way to test real touch behaviour on the sheet.
Guarded by #if DEBUG, exactly like the existing -qaCopyPostcode hook.
2026-10-14 12:25:16 +01:00
FuelBoard Contributor 6b41e8ac51 Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 12:07:54 +01:00
FuelBoard Contributor 1b2bdd46ea More info sheet: give the two station actions their own card below the map
Sharing the map's section made the buttons read as part of the map card and
the map tap target. They now sit in their own section, rendering as a
separate card directly beneath the map.
2026-10-14 12:07:54 +01:00
FuelBoard Contributor e58dbb7833 Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 11:52:42 +01:00
FuelBoard Contributor 6983fa83cc More info sheet: put Directions and Report issue on one line under the map
The two station actions now sit together directly beneath the map card, each
taking half the width, instead of being two separate rows at the foot of the
sheet.
2026-10-14 11:52:41 +01:00
FuelBoard Contributor 7ee1f06ce6 Merge branch 'feature/best-value-candidate-fill' into integration/more-info-and-best-value 2026-10-14 11:34:33 +01:00
FuelBoard Contributor 6ea675499e Best Value detail sheet: keep the verdict capsule left-aligned
The capsule was right-aligned on its own line; the user asked for it back at
the left margin, leaving the Total row as it is.
2026-10-14 11:31:59 +01:00
FuelBoard Contributor 68139e6690 More info sheet: overlay the map tap target, move the copy line below the address
The map tap target is now an OVERLAY button on top of the Map. A Map wrapped
in a Button inside a List row never received the touch, so the first attempt
left the card inert. The Map stays non-interactive and the overlay owns an
explicit rounded-rect content shape.

The copy glyph and its Copied confirmation move onto their own always-present
second line, so the confirmation can no longer reflow the address above it.
Verified: the address line's ink-pixel runs are identical with and without
the confirmation.
2026-10-14 11:26:21 +01:00
FuelBoard ContributorandFuelBoard Contributor d537667aa3 More info sheet: tappable map opens directions, address copies postcode 2026-10-14 11:26:08 +01:00
FuelBoard Contributor 3c424ab397 Best Value: candidate-priced fills, key copy, detail total 2026-10-14 10:43:12 +01:00
FuelBoard Contributor 2ed9cbf4e8 Show return distance on station rows 2026-10-14 08:19:14 +01:00
FuelBoard Contributor 4811b1cfa5 Gate detail diagnostics behind debug trigger 2026-10-14 07:54:32 +01:00
FuelBoard Contributor 6f177837a5 Stop Approx. distances beside routed values; add Best Value alert mode
Distances (the "Approx. 1.5 mi" report):
- Refuse junk CoreLocation fixes (invalid accuracy, or a coarse >300 m fix
  within 3 km of the position already held; a coarse fix in another city is
  real travel and is still accepted).
- Resolve ONE display basis per render through FuelStore.distanceDisplayOrigin,
  used by rows, radius filter, sorting, savings figures and the Live Activity,
  so a stray fix can no longer re-base the list outside the route cache's 600 m
  ring while the routed values sit unused in the cache.
- Treat an incomplete route set as a state: retry it after 30 s rather than the
  full 10-minute throttle, and repair a stale incomplete flag once every
  in-radius station has a route (nothing left to route, flag stuck down).
- Widget extension resolves the same origin for fetch, radius, ordering and
  labels, and keeps its own fix in fuelboard.widgetLocation instead of
  overwriting the app's GPS fix.

Alerts:
- Best Value as a distinct alert mode on the existing geofence/notification
  pipeline, with a persisted threshold; reverts to cheapest when the feature is
  unconfigured and is not selectable while disabled.

Diagnostics (DEBUG, gated): FB_DIST_FOCUS now prints FB-DIST-ORIGIN (candidate,
accuracy, anchor, offset, resolved branch, served origin), FB_FAKE_FIX feeds a
synthetic fix through the real guard, FB_STALE_INCOMPLETE forces the stale-flag
state, and the route-fill status is surfaced in Settings behind debug mode.
2026-10-13 22:04:00 +01:00
FuelBoard Contributor 2a2823c180 Accurate distances toggle, Approx. distance markers, and Closest-mode speedup
Distance confidence
- Every distance surface takes the figure AND its "Approx." marker from one
  FuelStore.stationDistanceDisplay call (station rows, detail sheet, widget,
  Live Activity, watch, Siri), so a surface can never mark one figure approximate
  while showing a routed one. Radius filters and Closest ordering test that same
  displayed distance, so no row can read a distance outside the radius that
  filtered it (the reported "14.6 mi inside a 10-mile list").

Accurate distances (Settings -> Distances, default off)
- Off: the browse layer serves the straight line, marked Approx., for every row,
  so two installs agree to the metre and the app makes no route requests at all.
- On: routed distances for the whole search area, unmarked, served only from a
  COMPLETE set anchored within 600 m — a partial fill reverts every row at once
  rather than mixing road and straight-line bases.
- Best Value maths sits deliberately outside the toggle: it keeps using measured
  road routes and its own confidence marking, so money figures never move with
  the switch.
- Fills run only while the toggle is on and the app is frontmost; capacity raised
  to 600 stations per pass; completeness tolerates pins Apple Maps has no route
  for, which otherwise left an area permanently incomplete and the whole list
  stuck on estimates.

Performance (the reported slowdown switching to Closest / launching with it)
- FuelStore.accurateDistancesEnabled was a UserDefaults(suiteName:) read per
  call: a synchronous cfprefsd round-trip measured at ~1.9 us, ~200k calls per
  Closest sort. Memoised, with the setter keeping it in step.
- Both keyed sorts decorate their keys — one distance per station, carried as an
  index instead of copying whole stations into the decorated array — and the
  comparison baseline plus per-station journeys now come from ONE pass instead of
  two full scans per tab. The Stations tab also reuses its single sorted list for
  the count and the TOP badge instead of sorting the country three times.
- Measured on iPhone SE (7,914-station Closest pool): sort 36.6 ms -> 17.1 ms per
  list, Cheapest 6 ms, Best Value 11 ms.
- DEBUG probes: FB_PERF list timing, FB_PERF_BENCH toggle-read benchmark, and
  FB_DIST_FOCUS now reports the served value, marker and mode (and re-prints when
  a fill lands). FB_ACCURATE_DISTANCES / FB_SORT_MODE env forms for devices, where
  devicectl eats a leading-dash argument.

Tests: 198, 0 failures. Device and simulator Debug builds green.
2026-10-13 18:01:45 +01:00
FuelBoard Contributor e0a997ecb0 Fix Release archive build for Best Value diagnostics; bump build to 26 2026-10-13 13:16:52 +01:00
FuelBoard Contributor 8436e87398 Avoid duplicate Best Value rank crashes 2026-10-13 12:15:00 +01:00
FuelBoard Contributor 9aa8a2ede1 Clarify Best Value route confidence 2026-10-13 10:30:41 +01:00
FuelBoard Contributor d8a85b7aa0 Bump release version to 1.1.0 2026-10-13 09:15:07 +01:00
FuelBoard Contributor d5e7ff737f Best Value page: the button says Continue in both states
The declined state had its own "Not now" label, on the theory that declining
should read as a decision rather than a skip. In practice the toggle above it
already shows which answer is being given, so the second label only added a
second thing to read. The label is now an unconditional Continue for both
states, which also let primaryTitle drop the settings value it no longer
consulted.

Nothing else about the page changed: the toggle still hides the sliders when
off, the "can be turned on in Settings" note still appears when declined, and
the settings still save as the controls move.

Verified on the simulator (OCR of the rendered screens): the announcement page,
and the onboarding page with the toggle both off and on, all show Continue as
the bottom button. No "Not now" remains in the app's source. 180 tests,
0 failures.
2026-10-11 21:08:46 +01:00
FuelBoard Contributor bfa9209d68 Best Value: announce the page to existing users, once per announcement version
Users who finished onboarding before Best Value existed are "completed, not
fresh" forever, so the first-run gate never fires again for them and the feature
stayed invisible unless they went looking in Settings. They now get the Best
Value page on its own, once per announcement version — deliberately NOT a replay
of the onboarding flow, which would re-run permission pages they answered long
ago and has no skip.

- FuelStore: "fuelboard.bestValueIntroVersion", stored through the same
  saveString/loadString path as the onboarding flag and compared against a
  CODE-SIDE "currentBestValueIntroVersion". Deliberately not
  CFBundleShortVersionString: keying on the bundle version would re-announce on
  every bugfix release. Bump the constant to re-announce on purpose.
- FuelStore.shouldAnnounceBestValue(settings:hasSeen:onboardingDue:isSuppressed:)
  holds the rule, with every fact injectable so it is unit-tested instead of
  being driven through a simulator. It declines when onboarding is due or on
  screen, when this announcement version has been seen, and when the user has
  already configured Best Value themselves.
- BestValueAnnouncementHost wraps the app's content and owns the presentation
  state, so ContentView's change is a single wrapper and the whole feature lives
  in the new file. It reloads the stored settings on dismiss, because the page
  writes straight to the store rather than through ContentView's binding.
- The page is now ONE view, BestValueIntroContent, used by first-run onboarding
  (page 5 of 6) and by the announcement, so the copy, the sliders and the
  "declining is a decision" rule cannot drift. OnboardingView drops its private
  duplicate of the page, its bindings and its read-outs, and finish() marks the
  announcement seen so a new user never sees the page twice.

QA hooks (DEBUG): -showBestValueIntro forces the announcement, and -skipOnboarding
suppresses it. Worth knowing now that a returning install announces on a PLAIN
launch, so tab screenshots need -skipOnboarding.

Verified on the simulator (OCR of the rendered screens): a plain launch of an
install that has completed onboarding shows the single announcement page — Best
Value title, explanation, "Turn on Best Value", the "Settings, under Fuel
Savings" note and a "Not now" button, with no page dots; -skipOnboarding shows
no cover at all; -showBestValueIntro forces it. 180 tests, 0 failures (8 new).
2026-10-11 20:53:09 +01:00
FuelBoard Contributor a69ee14e07 Onboarding: add a Best Value page before the final screen
New page 5 of 6, sitting between "Prices, ready when you are" and "You're all
set":

- Explains the feature: the cheapest pump isn't always the cheapest fill-up.
- Asks whether to switch it on, with a toggle.
- Switched ON, it asks for the two figures the ranking needs — average fuel
  economy and typical fill-up — using exactly the Settings slider pattern
  (read-out above a slider, £/Litres picker, no text entry anywhere).
- Switched OFF, it says where to switch Best Value on later ("Settings, under
  Fuel Savings") instead of showing controls the user has just declined, and
  the button reads "Not now" rather than "Continue" so declining reads as a
  decision.

Chosen figures are written to the store as the controls move — the same
immediate-persist rule as Settings — so swiping past the page cannot lose a
choice. ContentView now reloads the stored savings settings when onboarding
dismisses: onboarding is a full-screen cover writing straight to the store
rather than through ContentView's binding, so without the reload the choice
would not take effect until the next launch.

QA hooks (DEBUG only, alongside -tab / -skipOnboarding): -showOnboarding forces
the cover up (the app-group onboarding flag survives an app delete, so a
reinstall alone won't bring it back), -onboardingPage N opens straight on a
page, -onboardingBestValue on|off forces the page's choice.

Verified on the simulator: with the toggle off the page shows the title,
explanation, toggle, the "set it up in Settings" note and a "Not now" button;
with it on it shows the MPG slider (45 MPG), the £/Litres picker, the fill-up
slider (£40), the "change these in Settings" note and "Continue". Page 5 is
still "You're all set". Checked on an iPhone 17 Pro and on an iPhone SE (3rd
gen) screen — nothing clips on the small one.

172 tests, 0 failures.
2026-10-11 20:33:30 +01:00
FuelBoard Contributor f1d9e9591d Settings: Fuel Savings moves to the first section
It was the third block in the List (after Units and Tap action), so the feature's
own controls — and the MPG/fill-up figures that drive the Best Value ranking —
sat below unrelated settings. The section (header, toggle, sliders, footer) is now
the first thing in the List; Units and Tap action follow in their existing order,
unchanged.

The move is a pure reorder: no copy, no binding, no gating changed. The section
still greys its controls out when Best Value is switched off, and the footer
narrative is unchanged.

Verified on the simulator: Settings opens with Fuel Savings -> Best Value ->
Average fuel economy / 45 MPG, with Units and Tap action below.
2026-10-11 20:20:37 +01:00
FuelBoard Contributor d348157a45 Best Value: rename the remaining user-facing copy, hide the sheet section when off
Following the sort-segment rename, the rest of the user-facing "Worth the Drive"
copy moves to "Best Value".

- Settings: the toggle is now just "Best Value" (was "Enable Worth the Drive"),
  and the narrative footer opens "Best Value estimates fuel cost only, …".
- More info sheet: the section header is "Best Value" (was "Worth the drive?"),
  and the section itself is now conditional. `fuelSavings` is nil whenever the
  feature is off — `savingsLookup` returns nil when it is not configured — so
  the heading is never left dangling over an empty section while the feature is
  disabled.
- The unavailable text reads "Best Value unavailable: …".
- Comments and doc comments that named the sort were updated so they match the
  UI instead of contradicting it.

Deliberately unchanged: the enum's raw value stays `worthTheDrive`, so stored
sort preferences and widget configs keep decoding, and FuelSavingsStatus keeps
its verdict wording ("Worth the drive" / "Not worth the drive") — that describes
a station's verdict rather than the feature, and is not shown in the UI.

Verified on the simulator: with the feature OFF the more info sheet shows
Address / Distance / Directions and no heading at all; with it ON the sheet
shows the "Best Value" section; Settings reads "Fuel Savings" / "Best Value"
with no "Enable" anywhere. 172 tests, 0 failures.
2026-10-11 20:11:57 +01:00
FuelBoard Contributor 801f969644 Stations sort: "Worth the Drive" segment is now "Best Value"
The third sort segment, the caption line it drives on the Stations tab, and the
Settings sentence about sorting now read "Best Value" — that is
SortMode.worthTheDrive.displayName plus the two copy sites that name the sort.

The feature that backs it keeps the name "Worth the Drive": the Settings toggle
("Enable Worth the Drive"), the Fuel Savings section header and footer, the sheet
header "Worth the drive?", the "Worth the Drive unavailable: …" text and
FuelSavingsStatus.worthTheDrive's own wording are all unchanged.

The enum's raw value stays `worthTheDrive`, so stored sort preferences and widget
configs keep decoding — only the label moved.

3 new tests pin the three sort names, the stability of the raw values, and the
fact that the feature's own wording did not change (172 total, 0 failures).

Verified on the simulator: the Stations segmented control reads
Closest | Cheapest | Best Value.
2026-10-11 20:01:32 +01:00
FuelBoard Contributor 79c012b405 More info sheet: drop the Name row — the header already shows it
The sheet's header (navigationTitle, .inline) shows the station name, and the
address row no longer leads with the name either, so the Name row restated the
header immediately above the address. The section now opens straight with
Address; distance, Worth the drive and the buttons are untouched.

Verified on the simulator: the sheet reads "Sainsbury's Halifax" in the header
with rows "Address / WADE STREET, HX1 1SB" and "Distance / 0.5 mi", and no Name
row. No other surface in the app prints a Name row.
2026-10-11 19:40:11 +01:00
FuelBoard Contributor bb053de8b1 Listing rows: verdict text hugs its icon, and the address stops repeating the name
Two things the listing row was saying twice, or too loosely.

- The verdict capsule used a Label, and a Label lays its icon and title out
  across the width it is given: in a listing row that left 22pt of empty space
  between the symbol and "Save £1.07 after travel". It is now an HStack with
  an explicit 3pt spacing (5pt in the detail sheet), so the text sits beside its
  icon and the capsule background hugs the pair. Measured on the same row: the
  capsule text moved x 412 -> 341 and the pill 270-831 -> 270-691.

- The address line printed the station name a second time. The official Fuel
  Finder address field usually opens with the station's own name ("CHIDSWELL
  SERVICE STATION, LEEDS ROAD, DEWSBURY, WF12 7QW" for "Jet Chidswell"): 14% of
  the dump's addresses start with the exact name, and 13% also end with the
  postcode the row appended again. StationAddressLine (new, in Shared/, so the
  row and the detail sheet share one rule) drops a leading repeat of the name
  and writes the postcode exactly once.

The rules are deliberately conservative: a leading segment carrying a house
number is a real street address and is left alone ("41 ANNACLOY RD ANNACLOY"),
and a partial-word match ("Jet" against "JETSON FILLING STATION") never cuts
into a word. 14 new tests (169 total, 0 failures) pin the shapes found in the
real dump as well as the examples above.

Verified on the simulator: the first listing row's second line went from
"CHIDSWELL SERVICE STA..." to "LEEDS ROAD, DEWSBURY...", and the detail sheet
reads "Sainsbury's Halifax / WADE STREET, HX1 1SB".
2026-10-11 19:33:26 +01:00
FuelBoard Contributor 85f601fffd Fuel Savings: the feature's own settings grey out when it is switched off
With "Enable Worth the Drive" off, the MPG slider, the fill-up unit picker
and the fill-up slider stayed live — editable settings for a switched-off
feature. They now sit in a Group disabled by `enabled`, so they grey out but
stay on screen and keep their values (the same rule as the distance picker
in Closest mode), and re-enabling restores exactly what was there.

The surfaces that USE the feature were already gated on `isConfigured`
(which includes `enabled`), so the in-list fill-up control and the Worth the
Drive sort option still disappear entirely rather than greying out.

Verified on the simulator with the toggle off vs forced on: the MPG slider
row and the unit picker row both measurably dim — MPG row mean RGB
33.06/35.62/39.49 off vs 37.61/42.70/48.43 on; picker 40.00/40.00/42.62 vs
53.35/53.36/56.82 — while the toggle row itself changes as expected
(54.85/64.77/58.96 with the switch on).
2026-10-11 19:15:26 +01:00
FuelBoard Contributor cf3abb3fa7 Fuel Savings: MPG and fill-up are sliders, not typed fields
The two Fuel Savings figures and the in-list fill-up control were text
fields. A typed figure could sit outside the slider's own bounds, so the
control and the number it claimed to show could disagree, and the decimal
pad needed a Done button to escape.

- Settings: "Average fuel economy" and "Typical fill-up" are now sliders
  with a read-out ("45 MPG", "£40" / "40 L") in place of the field. The
  warning copy reads "Set a realistic..." rather than "Enter...".
- FuelSavingsAdjustmentView (shared by the Stations Worth-the-Drive header
  and the Favourites tab) drops its text field and gains the same read-out;
  the typed-value machinery and the -focusFillAmount QA hook go with it.
- Slider bounds live in FuelSavingsCalculator (fillAmountSliderRange/Step,
  mpgSliderRange/Step) so the Settings control and the in-list control can
  never offer different ranges, and the fill-up slider range stays inside
  the calculator's validity ceiling (asserted by test).
- Two tests pin those invariants: every slider position is a valid amount,
  and both MPG bounds are valid MPG values. 155 tests, 0 failures.
- Comments that described the removed fields were updated; the keyboard
  Done-bar plumbing is kept (no numeric field remains in the app, so it can
  no longer fire) rather than deleted, so any field added later still has an
  escape route.

Verified on the simulator: Settings shows "Average fuel economy / 45 MPG"
with no field, and the Worth-the-Drive fill-up control shows the unit picker
with a "£40" read-out and a slider. The Settings "Typical fill-up" row sits
below the fold on every simulator available here (and synthetic input cannot
scroll it), so those pixels are unverified.
2026-10-11 17:41:53 +01:00
FuelBoard Contributor d0a89d97d5 Worth the Drive: calculation audit + listing/detail polish
Calculation audit (brief config: 30 MPG imperial, £60 fill-up, baseline
Rontec Shelf 171.9p at 0.4 mi):

- One mile<->kilometre constant pair (kilometresPerMile 1.609344,
  metresPerMile 1609.344) replaces the mixed 1609.34 / 0.621371 / 1.60934
  used in the savings path. Effect ~2 ppm (invisible), but the conversions
  are now auditable in one place.
- New FuelSavingsCalculationAuditTests (20 deterministic tests, no live
  MKDirections) pins the brief's three stations - Tesco 170.9p/2.0 mi,
  MFG Hare & Hounds 172.9p/2.8 mi, PGG Napoleon 171.9p/2.8 mi - plus
  imperial-vs-US gallon, MPG and budget scaling, the break-even tolerance
  boundary, the nearer-candidate clamp, route-unavailable, mixed-basis
  estimates and display-rounding boundaries.
- Formula unchanged, and verified correct: fixed quantity (budget / baseline
  price), pump saving, round-trip extra distance clamped at zero, travel fuel
  priced at the candidate station, +/-5p break even, baseline labelled
  "Closest station", sorting on the unrounded net saving. The brief's "£0.18"
  for Tesco is the rounded 1 dp extra-distance caption (2.02-2.05 mi reads as
  "2.0 mi"), not a rounding bug in the money.

UI (uncommitted since d7a0ecb):

- Done bar drawn in the bottom safe area from keyboard notifications:
  .toolbar(placement: .keyboard) is device-dependent (present on the 15 Pro,
  absent on the SE on the same iOS build).
- Favourite star in the More-info heading, wired to the same fuel-scoped
  toggle as the listing rows at all three call sites.
- CHEAPEST/TOP badge moved above the price so it reads as part of it.
- Listing rows carry the real session fill-up amount instead of a hard zero
  (the compact banner does not print it today; a future row layout would).

153 tests, 0 failures. Simulator and device builds succeed.
2026-10-11 17:19:10 +01:00
FuelBoard Contributor d7a0ecb79a Add Worth the Drive fuel savings with road-routed estimates
Optional Fuel Savings feature (off by default): vehicle MPG in UK imperial gallons, fill amount in pounds or litres, persisted through the existing keychain/app-group store. Ranks the WHOLE station pool by estimated net saving (fuel-price saving at each pump minus the extra return-trip driving cost) using MapKit road distances with a straight-line fallback marked (est.).

The closest station with a valid price for the selected fuel is the comparison baseline and is labelled "Closest station"; other rows read "Save £X.XX after travel" / "£X.XX worse off after travel" / "Break even". Labels live in the shared model so they are unit-tested. The TOP badge still marks the cheapest pump price and reads "CHEAPEST" when the list is ranked by net saving.

Road routing now prioritises the Worth-the-Drive candidates plus the baseline and merges cache entries instead of replacing them, so far-out stations get real routes rather than permanent straight-line estimates. Numeric fields gain a Done button above the keypad (decimalPad has no Return key); the fill-up control is hidden unless the feature is configured; Favourites shares one session fill amount with the station list. 131 tests.
2026-10-11 14:34:53 +01:00
FuelBoard Contributor 1044d009ae Refine onboarding permission flow 2026-10-11 09:44:02 +01:00
FuelBoard Contributor 230f9c751b Refresh bundled fuel dump 2026-10-01 16:43:12 +01:00
FuelBoard Contributor 13d84eb32d Localize alerts and favourites copy 2026-10-01 15:25:08 +01:00
FuelBoard Contributor 824923b2ac Localize onboarding copy 2026-10-01 12:54:43 +01:00
FuelBoard Contributor 50c543c776 Harden App Store build for review 2026-10-01 12:19:05 +01:00
FuelBoard Contributor c7731d2b36 Fix App Store background location mode 2026-10-01 07:44:40 +01:00
FuelBoard Contributor 7633a8df90 Add FuelBoard device and IPA helpers 2026-09-26 20:03:40 +01:00
FuelBoard Contributor b8f267814c Update FuelBoard assets and bundled data 2026-09-26 20:03:35 +01:00
FuelBoard Contributor c3a975eb10 Harden watch sync and App Group storage 2026-09-26 20:03:29 +01:00
FuelBoard Contributor 004fe2f787 Configure FuelBoard XCTest target 2026-09-26 20:03:20 +01:00
FuelBoard Contributor 6900345aab Ignore generated build directories 2026-09-26 19:34:50 +01:00
FuelBoard Contributor d0ee8edac3 Document and support StoreKit sandbox testing 2026-09-26 19:34:41 +01:00
FuelBoard Contributor 20f53b9290 Improve StoreKit tip diagnostics and pricing 2026-09-26 14:38:32 +01:00
FuelBoard Contributor 2921271be7 chore: update build settings and screenshots 2026-09-20 14:43:01 +01:00
FuelBoard Contributor 7cb23e09c2 fix: retry StoreKit tip product lookup 2026-09-20 14:33:28 +01:00
FuelBoard Contributor 7dbd48786f docs: add FuelBoard privacy policy 2026-09-19 14:04:23 +01:00
FuelBoard Contributor 3a1ef0bd59 feat: indicate Costco membership requirement 2026-09-19 08:10:15 +01:00
FuelBoard Contributor 551a40ddf0 feat: show station info on long press 2026-09-19 07:41:52 +01:00
FuelBoard Contributor 3c83c9b518 fix: shorten report confirmation action 2026-09-17 22:30:25 +01:00
FuelBoard Contributor 6c8fd8b0de feat: add GOV.UK report issue action 2026-09-17 22:18:27 +01:00
FuelBoard Contributor 44cbd3b1e4 feat: route all Live Activity island taps through Tap station action
Expanded leading/trailing, compact and minimal island views had
no Link, so tapping them just opened the app with no destination.
Every island region now links to the same tap destination as the
Lock Screen card (Maps vs in-app detail).

Tap hint captions follow the setting too: Tap for details vs
Tap for directions.
2026-09-16 16:30:27 +01:00
FuelBoard Contributor 20fe251b8e feat: honour Tap station action in widgets
Small widgetURL and medium list Links route through
StationTapAction via FuelStation.tapDestinationURL: Open map
keeps the Maps URL, More info uses a fuelboard://station
deep link (stationID plus coordinate/name fallback).

Deep link carries fallback coords; ContentView presents the
detail sheet when the station is cached, else the map sheet.
Live Activity detailURL upgraded to the same rich link.
2026-09-16 16:15:07 +01:00
FuelBoard Contributor 465e6cd4d4 feat: honour Tap station action on notification and Live Activity taps
Notification body/action taps route through StationTapAction:
Open map opens Apple Maps (existing); More info presents the
in-app detail sheet (falls back to the map sheet when the
station is not in cache).

Live Activity Lock Screen card and island station row pick
their Link destination the same way: Maps URL for Open map,
fuelboard://station deep link for More info, handled in
FuelBoardApp and observed by ContentView.

Also adds the small non-interactive map preview above the
Directions button in the More info sheet.
2026-09-16 13:11:36 +01:00
FuelBoard Contributor 1299b4a004 feat: split Tap action into its own Settings section
Moves the Tap station picker out of Units into a dedicated Tap
action section, with the tap descriptor text as its footer. Units
footer reverts to distances/prices only.
2026-09-14 22:11:23 +01:00
FuelBoard Contributor cc948dac3a feat: station tap action toggle (open map vs more info)
Settings → Units gains a Tap station picker: Open map (default,
historic behaviour — tap opens Apple Maps directions) or More info
(tap shows a detail sheet with name, address, distance from current
location, and a Directions button). Applies to Stations and
Favourites tabs; persisted keychain-first as
fuelboard.stationTapAction. Stations footer hint follows the setting.
2026-09-14 22:03:20 +01:00
FuelBoard Contributor ac44cb4ce7 Merge feature/watch-app into main 2026-09-14 21:39:18 +01:00
FuelBoard Contributor abc737eb2d test: symlink DataRefreshMode into test target
FuelStore references DataRefreshMode; without the symlink the
SPM test target fails to compile. 107 tests pass.
2026-09-14 21:39:14 +01:00
FuelBoard Contributor 3d5c999dc2 build: align embedded bundle versions for archive 2026-08-31 11:35:22 +01:00
FuelBoard Contributor d899409b78 build: align all target build numbers to 3 2026-08-31 11:18:05 +01:00
FuelBoard Contributor 8bbcb0b4dc watch: polish refresh status and compact live activity 2026-08-31 10:40:16 +01:00
FuelBoard Contributor 767080a983 watch: use MapKit directions handoff 2026-08-31 08:23:07 +01:00
FuelBoard Contributor 7a84364238 watch: fix snapshot sync and polish favourites UX 2026-08-31 07:48:20 +01:00
FuelBoard Contributor 486b8cff2e Fix watch fuel icons and snapshot sync 2026-08-30 23:35:59 +01:00
FuelBoard Contributor d40bcaeca0 Fix watch icon pixel sizes for archive validation 2026-08-30 23:11:27 +01:00
FuelBoard Contributor f2fb075f2b Fix watch app name and unify watch icon assets 2026-08-30 22:58:05 +01:00
FuelBoard Contributor 09740fca18 Fix watch build, status sync, and icon assets 2026-08-30 22:26:30 +01:00
FuelBoard Contributor ebda21436d Restore smart settings and sync watch favourites 2026-08-30 18:57:57 +01:00
FuelBoard Contributor 0997dcea45 Set watch app team for signing 2026-08-30 13:35:16 +01:00
FuelBoard Contributor a59591d6ca Merge main into feature/watch-app 2026-08-30 13:10:59 +01:00
FuelBoard Contributor 50036c8a67 Signing: set FuelBoard team for app and widget 2026-08-28 19:00:43 +01:00
FuelBoard Contributor a71b7ae20e Siri: remove Apple from App Intent descriptions 2026-08-28 17:48:51 +01:00
FuelBoard Contributor 98cf3a5736 Git: ignore build-watch output 2026-08-28 16:35:32 +01:00
FuelBoard Contributor ed41df46de siri: drop generic shortcut and broaden fuel-word phrases
Siri was hit-and-miss on 'unleaded near me' (\u2018can't do that, searching
in app\u2019 fallback). The generic parameterized AppShortcut ('cheapest
${fuel}') competed with the fixed-fuel literal-word shortcuts for the same
intent, degrading NLU ranking; it was already documented as flaky. Remove it
(now 9 shortcuts, under the 10 cap) and add natural phrase variants for
unleaded/petrol ('What's the cheapest unleaded near me', 'Cheapest unleaded
near me', 'Where's the cheapest petrol'). All phrases keep .applicationName
per the iOS 26 metadata requirement. Build-verified in extract.actionsdata:
generic gone, new phrases registered.
2026-08-20 19:27:34 +01:00
FuelBoard Contributor 9342ccc7c9 trends: redraw lines on 7/30/90 selection
Callers previously morphed the existing lines on a reload, so picking a new
range only glided in place. Now every range/fuel selection (the only reloads;
the sheet has no periodic refetch) replays the staggered per-series draw-in
via revealSeries(), so the lines visibly redraw left->right when the user
switches 7/30/90. Price<->vs-cheapest still morphs (pure view toggle, no
reload). Reduce Motion jump-cuts.
2026-08-20 18:49:54 +01:00
FuelBoard Contributor 508ccc3802 trends: staggered per-series line draw-in
The single-waterline trace-in was invisible with sparse data (2 points draw
in one step). Replace it with a per-series waterline: each station's line now
traces left->right on first appearance, series revealed 0.18s apart in a
cascade so the draw is clearly visible. Newly-added favourites get a full
reveal immediately on reload; Reduce Motion still jump-cuts. Range/mode
morphs and numeric headline transition unchanged.
2026-08-20 18:40:09 +01:00
FuelBoard Contributor 69c3c17606 trends: animate the price-history lines (subtle, Reduce-Motion aware)
- Reloads (range/fuel switch, Price<->vs-cheapest) now glide the existing
  lines + y-axis to the new data via withAnimation/.animation(value: mode)
  instead of snapping.
- One-time left->right trace-in on first data appearance by revealing an
  increasing prefix of each series' points (area + line stay in sync); later
  switches morph rather than re-trace.
- Headline average rolls its digits with .contentTransition(.numericText).
- All animation honours .accessibilityReduceMotion (jump-cut to final state).
Kept two-pass area/line ordering, explicit series:, bounded fill bands, and
tight chartYScale unchanged.
2026-08-20 17:54:55 +01:00
FuelBoard Contributor 0b27d3d4f6 road distance: pin-fingerprint guard so embedded/live/cached never cross-pollute
The road-distance cache was keyed purely by station ID with no coordinate, so
a value routed to one pin could be served for a same-ID station whose stored
coordinate came from a different source (live fetch vs bundled offline dump
vs a corrected pin), reproducing Maps mismatches.
- RoadDistanceCache now stores each routed pin (CachedRoadDistance{meters,
  lat, lng}) and roadDistanceMeters() only serves a value when the displayed
  station's coordinate matches the pinned one (within ~11 m).
- Kept the existing origin-distance staleness guard.
- Verified embedded sample data (ids y1/se1/...) never collides with real
  relay IDs, and the widget STRICT radius filter drops far-offline samples, so
  no actual leak existed in practice — this closes the theoretical stale-pin
  channel and future-proofs against coordinate fixes.
2026-08-20 17:26:33 +01:00
FuelBoard Contributor 01517ba687 onboarding: replay on a fresh/reinstalled install, not every launch
The keychain-first completed flag persists across reinstalls (same bundle id),
so a new install was treated as already-completed and onboarding silently
skipped while permissions still prompted. Add install identity:
- Store an install id in BOTH the app-own container defaults (wiped on
  reinstall, persists across launches) and keychain (survives reinstall).
- isFreshInstall() is true on first-ever launch or when the local id is
  missing/different from keychain (i.e. reinstalled); it seeds a fresh common
  id so the next launch of the same install is not 'fresh' again.
- shouldShowOnboarding() = not completed || fresh install -> onboarding now
  replays once per fresh/reinstall, never on ordinary launches.
Works on free SideStore (no app-group), using the app's own defaults domain
as the same-install signal.
2026-08-20 13:12:53 +01:00
FuelBoard Contributor d73a64023f road distance: trigger at launch + foreground, fall back to saved location
Road-distance routing previously only fired once a fresh GPS fix arrived via
the location hook/onChange. A returning user on a cold launch waited for that
fix, and if onboarding/flag issues kept tracking from starting, distances
stayed straight-line. Now:
- refreshRoadDistancesIfNeeded() uses the last saved location when the live
  fix isn't set yet, so it can run immediately.
- It's called explicitly on launch (already-onboarded branch), on foreground
  activation, and on the existing location paths.
2026-08-20 12:56:16 +01:00
FuelBoard Contributor dc6d1fd724 Merge branch 'fix/onboarding-persistence' into test/road-onboarding 2026-08-20 12:50:38 +01:00
FuelBoard Contributor 10e3b54d0c onboarding: persist completed flag keychain-first so it doesn't re-show
The completed flag lived only in app-group UserDefaults
(UserDefaults(suiteName: appGroupSuite)). On free SideStore accounts the
app-group container isn't provisioned, so the suite read as nil: save was a
silent no-op and load always returned false -> onboarding re-appeared on
every launch even after finishing and granting all permissions.

Switch to saveString/loadString (keychain-first, app-group mirror) under
onboardingCompletedKey, matching favourites/distance-unit persistence which
survives reinstall and works without a provisioning group. loadString falls
back to the app-group mirror, so anyone who previously completed on a
provisioned group keeps their flag; users on unprovisioned groups complete
once more and then it sticks.
2026-08-20 12:27:09 +01:00
FuelBoard Contributor 90fb3b7385 road distance: cover all in-radius stations, not just nearest 12
Stations past the nearest-12 cutoff silently fell back to straight-line,
which explained the remaining app-vs-Apple-Maps gaps (e.g. 3.5 straight-line
vs 5.9 road). Raise the per-pass cap to 40 and route every station within a
25 km straight-line radius (covers the 15 mi max search window) so any station
the widget/Live Activity/list can show gets a real road distance.

Also harden the new cache tests against persistent keychain state across
invocations (reset in setUp) so they pass deterministically.
2026-08-20 11:54:23 +01:00
FuelBoard Contributor 64ef260959 widget + live activity: prefer Apple-Maps road distance, computed by the app
The widget/Live Activity showed straight-line haversine distance (0.8 mi)
while Apple Maps routes 1.8 mi. Road routing is too heavy for the widget
execution + ~40-70/day refresh budget, so the APP now computes it:

- New RoadDistanceService (app target): for the nearest 12 stations each
  pass, calls MapKit MKDirections (free, no API key, matches Apple Maps)
  and caches metres keyed by station ID.
- Cache stored in KEYCHAIN (fuelboard.roadDistances) so the widget reads it
  even on free SideStore accounts with no app-group container; only valid
  within 600 m of the location it was built from.
- Throttled: recompute max every 10 min, or when the user moves > 400 m;
  wired into the location-update hook + location onChange.
- Widget face, app station rows + Live Activity show the cached road
  distance, falling back to straight-line when absent.
- 4 new cache tests (105 total).
2026-08-20 11:33:47 +01:00
FuelBoard Contributor 28bb245c93 live activity: fix vertical centering on lock screen
GeometryReader as the layout root pinned content top-left, and forcing
maxHeight:.infinity over-claimed the whole proposed height, centring the
content below true vertical centre (bigger gap above) on the Lock Screen.

Demote GeometryReader to a background side-channel that only measures the
slot width for the rich-vs-compact branch; the content stays intrinsic-sized
so the system vertically centres it itself.
2026-08-20 10:16:21 +01:00
FuelBoard Contributor d8412c132a live activity: remove the white/grey background circle behind the pump
User: 'Remove the white background circle from the live activity'. Drop
the explicit Circle badge and render the fuel-tinted fuelpump.fill on its
own (sized up 19 -> 28 since it no longer sits inside a disc).
2026-08-19 22:52:50 +01:00
FuelBoard Contributor 1d62204a7a live activity: explicit badge (dark circle + bright pump); fix alignment look
fuelpump.circle.fill's first hierarchical style hits the PUMP, not the
circle, so the earlier darkened colour landed on the pump and the circle
stayed white (user: 'I didn't want the fuel pump darker I wanted the
white circle darker'). Rebuild the brand glyph from two explicit shapes:
a light-grey (.86) Circle badge + a bright fuel-tinted fuelpump.fill, so
circle vs pump colours are unambiguous and the badge centres cleanly with
the two-line text block.

Add a DEBUG-only -qaLiveActivity <fuel> launch hook that starts a Live
Activity with a long station name so the Lock Screen can be rendered in
the Simulator and verified by pixels (pump bright green 43,193,81; badge
grey 202,201,202).
2026-08-19 22:46:57 +01:00
FuelBoard Contributor bdfbbc605f live activity: centre content vertically + darken pump circle
A GeometryReader hands its content the full proposed frame and pins it
top-left, so the Lock Screen card sat high instead of vertically centred
when the activity container offered extra height. Wrap the branch in
.frame(maxWidth:.infinity, maxHeight:.infinity, alignment:.center).

Also darken the pump circle to 82% of the raw fuel tint (mix 18% black) so
the white pump symbol keeps contrast against bright Lock Screen/card, esp.
for the light yellow premium / cyan diesel palette colours.
2026-08-19 22:26:37 +01:00
FuelBoard Contributor 2c6e63f6b1 live activity: tint pump glyph by monitored fuel
Move FuelType.tintColor (green=unleaded #30D158, yellow=premium #FFD60A,
cyan=diesel #64D2FF) into Shared/FuelStore.swift so the app, widget, and
Live Activity all read one definition (was app-only in StationsView, so the
widget couldn't see it). Tint the Live Activity rich card pump icon and the
Dynamic Island compactLeading pump by context.state.fuel.tintColor.
2026-08-19 22:18:56 +01:00
FuelBoard Contributor a6620d36f7 live activity: select rich vs compact by actual GeometryReader width
ViewThatFits measures each child's IDEAL (untruncated) width to decide
fit, and a flexible .frame(maxWidth:.infinity) middle column does not cap
that ideal — so a long station name (e.g. 5-mi Phoenix Filling Stations)
inflated richBody's ideal past the iPhone Lock Screen width and fell back
to the compact strip. Replace the ViewThatFits branch with a
GeometryReader that reads the ACTUAL proposed width and shows the full
three-column card at/above 280pt, compact below. Content-independent, so a
long name can no longer collapse the iPhone card.
2026-08-19 22:08:07 +01:00
FuelBoard Contributor 227cce4aa6 live activity: fix compact strip string interpolation (double backslash)
The compact CarPlay/Watch strip's second line had escaped interpolation
(\\( instead of \\() so it rendered the literal placeholder text
"context.state.stationName · context.state.distanceText" instead of the
actual station name and distance. Normalize to single-backslash interpolation.
2026-08-19 20:01:14 +01:00
FuelBoard Contributor 01bb3ba905 live activity: compact CarPlay strip shows fuel + price + station distance
Restore .supplementalActivityFamilies([.small]) + a ViewThatFits compact
fallback so CarPlay's small slot renders our shared compact strip (fuel type,
bold price, station · distance) instead of falling back to the Dynamic Island
compact closure, which showed the app name and omitted the distance the user
wants. Full iPhone/iPad card is preserved via the richMinWidth gate on
richBody plus its flexible, truncating middle column (long names can no longer
collapse it).
2026-08-19 18:31:36 +01:00
FuelBoard Contributor e586e5ecbf Merge main into feature/watch-app
Bring watch branch up to date with main (banner fade, onboarding timing,
Live Activity full-width layout). Resolved the bundled fuel dump data-refresh
conflict by taking main's newer snapshot (data_updated 2026-08-17T15:01, 8028
stations).
2026-08-19 18:11:13 +01:00
FuelBoard Contributor 5d26509034 live activity: always render full card (drop compact fallback)
Long station names inflated richBody's IDEAL width, so ViewThatFits (which
measures ideal, untruncated text) decided 'rich doesn't fit' and fell back to
the compact strip — that's why 5-mi / long-named activities rendered small
while 10-15-mi / short names stayed full. With .supplementalActivityFamilies
already removed, the body is only ever Lock-Screen width, so drop ViewThatFits
and compactBody entirely and always render the full three-column card; the
station caption line-limits + scale-downs + tail-truncates in place.
2026-08-19 16:39:49 +01:00
FuelBoard Contributor 221b96097d live activity: drop .small family so iPhone/iPad show full-width card
The active activity was rendering the narrow .small form on the iPhone Lock
Screen (squeezed, small text) because .supplementalActivityFamilies([.small])
made iOS eligible to present it that way. Remove it so iPhone/iPad always get
the full three-column card; CarPlay's small form still comes from the Dynamic
Island compact closures. Keep compactBody as a narrow-width safety fallback.
2026-08-19 16:09:02 +01:00
FuelBoard Contributor b012232012 live activity: robust full-vs-compact split; scale long station names
The adaptive layout picks the full three-column view when there's Lock
Screen width and a compact price strip in the small CarPlay/Watch slot.
Tighten the discriminant (rich ≥290pt, compact ≤220pt) so iPhone/iPad
always get the full view and only genuinely small slots get the compact
strip. Long station names used to clip at inconsistent points across
the rich/compact/Dynamic Island views; both bodies now scale + tail-
truncate so the caption stays consistent regardless of station name.
2026-08-19 13:58:54 +01:00
FuelBoard Contributor 727ef6e58f banner: explicit opacity fade (transitions were unreliable)
SwiftUI's removal transition never actually played despite .id keying — the
tip banner vanished instantly on dismiss. Replace it with explicit opacity/
offset driven by withAnimation in reflectBanner (gated by a .task(id:
activeBanner) so presentation is reliable and prompt): a dismissal now
fades out + slides up over 0.35s, and a tip→network swap crossfades. Uses
the same state-driven pattern that fixed the HermesCall ticker fades.
2026-08-19 11:57:32 +01:00
FuelBoard Contributor 4c222cdf55 banner: key by value so tip fades out on dismiss
The banner sits in a single if-let slot; without an identity key, clearing a
tip that was covering a network/offline banner swapped content in place with
no transition (instantly vanishing). Keying the banner by its value (.id)
makes every change — tip→network or tip→nil — run the shared removal
transition, so the tip slides up and fades out when it auto-dismisses or is
tapped.
2026-08-19 11:10:29 +01:00
FuelBoard Contributor cfd435d12a banner: top-floating; fix offline-trigger cache; onboarding prompt timing
- Banner floats at the very top (over the nav/title area), never pushing the
  content below and staying clear of the list/pill.
- Offline banner now fires on refresh in airplane mode / no data: the mirror
  live chain bypassed the HTTP cache (+10s timeout) so an offline fetch really
  fails instead of silently re-serving a cached pointer+dump as a 'success'
  (which kept dataStatus .live and hid the banner).
- Onboarding permissions fire at the Continue/Allow tap BEFORE advancing, so
  the system prompt appears after the page is read and never covers the next
  page's animation (grant auto-advances).
2026-08-19 10:17:24 +01:00
FuelBoard Contributor 8936643e0e banner: float below nav bar (no glass pass-through) + slide-up dismiss
Position the floating banner just below the nav bar so it no longer overlays the iOS 26 Liquid Glass back/toolbar chrome behind it — tapping retry can no longer also select the control beneath. Banner now slides up + fades on dismiss/timeout (one style for all notifications); full-rect contentShape for a solid tap target.
2026-08-19 08:43:35 +01:00
FuelBoard Contributor ba83f9e1c0 banner: unify network+tip into ONE floating overlay (never layout-shifting)
Extract AppBanner; render offline/connection/tip through the single statusBannerCard chrome as a ZStack top overlay so it floats over content instead of shifting layout. Tip (auto-dismiss ~3.5s) takes precedence over network state.
2026-08-19 08:17:50 +01:00
FuelBoard Contributor 57dffb6c5e tip: show outcome in the shared status banner (auto-dismiss)
Tip purchase results now ride the SAME banner chrome as the network/offline
strip above the tabs instead of a hard-to-see Settings footnote. TipStore is
lifted to ContentView (the banner owner) and shared down to Settings. Each
outcome maps to an icon/tint; auto-dismisses after ~3.5 s; tap to dismiss.
Renderer refactored into a shared statusBannerCard(icon:tint:title:subtitle:
trailingIcon:action:) used by both the network strip (retry) and tips (x).
2026-08-19 07:48:10 +01:00
FuelBoard Contributor 2205a585f1 onboarding: animated feature icons 2026-08-19 07:09:54 +01:00
FuelBoard Contributor f9b70dbb9d anim: consistent bold crossfade in fuel-type picker 2026-08-19 07:09:54 +01:00
FuelBoard Contributor e00e3ff365 backlog: refine foresight live activity notes 2026-08-18 19:33:54 +01:00
FuelBoard Contributor 4f8bf96ca2 backlog: add foresight live activity investigation 2026-08-18 18:15:33 +01:00
FuelBoard Contributor 8945190da5 fix: keep live activity in sync with motion 2026-08-18 17:59:03 +01:00
FuelBoard Contributor 2e23dcb9ae ui: keep station brand badges white 2026-08-18 12:56:49 +01:00
FuelBoard Contributor db916cafcb ui: fix fallback station badge visibility 2026-08-18 12:47:49 +01:00
FuelBoard Contributor 1dd7836b8b assets: expand station brand icon coverage 2026-08-18 12:35:03 +01:00
FuelBoard Contributor e512cec958 data: refresh bundled fuel dump 2026-08-18 11:05:09 +01:00
FuelBoard Contributor dcd5517825 Live Activity: add distance to compact CarPlay layout 2026-08-18 10:00:19 +01:00
FuelBoard Contributor e004f25a31 feat: add real watch companion and dual IPA build workflow 2026-08-17 22:41:12 +01:00
FuelBoard Contributor cd2632377a Expand watch app backlog 2026-08-17 20:23:20 +01:00
FuelBoard Contributor f1c5923c88 Add favourite price-drop Check now action 2026-08-17 19:59:45 +01:00
FuelBoard Contributor 276e0ff99e Swap Live Activity header and toggle labels 2026-08-17 17:28:14 +01:00
FuelBoard Contributor bed39956a3 Move cheapest station label to Live Activity header 2026-08-17 17:21:31 +01:00
FuelBoard Contributor b24b59df6f Rename Live Activity fuel label 2026-08-17 17:14:51 +01:00
FuelBoard Contributor 2495c7e47e Finalize alerts tab labels 2026-08-17 16:58:23 +01:00
FuelBoard Contributor 923209b933 Reorder alerts sections and refine labels 2026-08-17 16:53:26 +01:00
FuelBoard Contributor 02ac8540f3 Tweak alerts tab copy 2026-08-17 16:45:55 +01:00
FuelBoard Contributor 6f0fc43085 Add cheapest-favourite price-drop alerts 2026-08-17 16:33:46 +01:00
FuelBoard Contributor 63084322f6 Refine trends and debug harness; clean up warnings 2026-08-17 14:06:26 +01:00
FuelBoard Contributor 68d970720a backlog: CT 111 rootfs grown to 8 GB (pve); open item retired 2026-08-16 21:19:39 +01:00
FuelBoard Contributor 9952a096a4 docs: CT 111 is source of truth; Mac clone read-only mirror 2026-08-16 21:04:44 +01:00
FuelBoard Contributor 49503bef99 backlog: relay+mirror moved to Proxmox CT 111 (fuelboard-relay), live push verified 2026-08-16 18:55:07 +01:00
FuelBoard Contributor e0e5b36503 Trends: price-history fetch failure with no data raises the connection banner
A history fetch that comes back empty BECAUSE the mirror is unreachable
(pointer probe failed) was only an in-sheet retry card — no global signal.
Now it also raises the red connection banner ('Check your internet
connection / Tap to try again'), so a network problem is visible on every
tab, not just inside the Trends sheet. A successful load clears the
banner (only when the banner is the connection banner — never clobbers
the offline-dump banner).

- TrendsView: onHistoryUnavailable/onHistoryRecovered closures fired from
  load() (loadFailed -> unavailable; hasAnyData -> recovered);
  -forceHistoryFailure QA hook (forces the unreachable state, skips
  auto-refresh like the other force-* hooks)
- FavouritesView: closures threaded through the Trends sheet init
- ContentView: wires them to dataStatus (.live -> .connectionProblem on
  failure; recovered clears only .connectionProblem)
- fuelboard-development skill: hook + wiring documented
2026-08-16 12:31:35 +01:00
FuelBoard Contributor a6dbe8c46e Banners: mention Stations-tab refresh; add connection-problem banner
Fix 1 — the offline banner's subtitle claimed 'pull to refresh' while that
gesture only exists on the Stations tab, yet the banner pins across ALL
tabs. Copy now says where the gesture lives: 'Pull to refresh on the
Stations tab'. The banner itself stays tappable everywhere (tap = retry),
so the retry path is never trapped on one tab.

Fix 2 — fetch-failed-with-cache case showed stale saved prices with NO
feedback (looked like a working live app). New second banner state
'Check your internet connection / Tap to try again' (red wifi.exclamation
mark, tap = retry) now appears whenever the fetch fails but a saved cache
is on screen. It clears on the next successful fetch.

- ContentView: offlineDataDate:String? -> dataStatus enum (.live /
  .offlineDump(date) / .connectionProblem) driving a shared statusBanner
  (icon/tint/title/subtitle); -forceConnectionProblem QA launch hook;
  auto-refresh skipped under either force-* hook
- Localizable.strings: subtitle key updated, +5 new keys (incl. date-less
  'Offline data' fallback + a11y labels)
- Tests unchanged (101 green) — pure view-layer change
2026-08-16 12:14:13 +01:00
FuelBoard Contributor bfeb494c03 Offline banner: label the bundled-dump fallback with its snapshot date
The no-network last resort (bundled real dump) now shows a tappable banner
above the tabs — 'Offline data from 15 Aug / Pull to refresh to update
prices' — so stale snapshot data is never presented as live. Banner date
comes from the dump's own data_updated stamp (FuelStore.offlineDataLabel,
ISO 8601 with/without fractional seconds). Cleared on any successful fetch.

- BundledDumpProvider.dataUpdatedStamp (JSONSerialization, app target)
- ContentView: offlineDataDate state, offlineBanner view (tip-card styling,
  orange wifi.slash + arrow.clockwise, tap = force refresh), refresh()
  clears/sets it, -forceOfflineDump QA launch hook (serves the dump and
  skips auto-refresh so the banner stays up for captures)
- Localizable.strings: 3 keys (banner title/subtitle/a11y)
- +3 tests: OfflineDataLabelTests (101 total)
2026-08-16 11:52:19 +01:00
FuelBoard Contributor 430006be94 Trends: chart key shows average price per station + descriptor footer
Legend rows now show each station's average over the days shown
in brackets (pence; signed +X.Xp in vs-cheapest mode, matching the
list-row deltas). Footer under the key explains the figure per mode.
averagePence helper (nil for empty series) + 3 tests; -showTrends
becomes a permanent QA hook like -showKeySheet.
2026-08-16 10:39:49 +01:00
FuelBoard Contributor 9aea478317 Trends: explicit series so each favourite draws its own line
LineMark lacked a series: value, so Swift Charts merged every
station's points into one polyline — only the first station's
line was recognisable. Adding series: per station splits the
chart into N coloured lines (legend colours now match).
2026-08-16 10:19:51 +01:00
FuelBoard Contributor 3416ad563e Key sheet: taller detent + opaque background
Sheet grows from .medium to .fraction(0.6) so the footer ('Colours
match each station's rating on the list') clears the bottom edge with
comfortable margin (verified: footer clearance +33pt). Sheet background
switched from the translucent default to a solid
systemGroupedBackground via .presentationBackground so the stations
list no longer shows through.
2026-08-16 09:57:58 +01:00
FuelBoard Contributor 87d66eae94 Key sheet: padding on TOP + star cards
The two bottom cards (TOP, star) previously had no padding — their
background hugged the text. Add .padding(.horizontal, 14).padding(.vertical, 12),
matching (slightly exceeding) the RAG cards' vertical padding so the
info cards read as proper cards.
2026-08-16 09:48:33 +01:00
FuelBoard Contributor 86385d53a2 Key sheet: card redesign matching the tip section
Price-rating legend now renders as five accent cards (tip-section style):
Best value (green, checkmark) / Okay (orange, equal) / Pricey (red,
exclamation) each with a RAG dot + threshold (≤1.5p / ≤4p / >4p) on the
right in the row's accent colour; TOP (blue capsule) and star (yellow)
as accent cards below a divider. Footer: 'Colours match each station's
rating on the list'. Copy split into title/blurb keys in Localizable.
Sheet chrome (medium detent, Done, info button) unchanged.
2026-08-16 09:18:27 +01:00
FuelBoard Contributor 9fdcc7370e Tip section: card design per user mockup
Three stacked rounded cards, one per tier, each with an accent-tinted
fuel-pump icon and price (Splash & Dash #64D2FF blue, Half a Tank
#30D158 green, Fill 'Er Up #FFD60A amber — the app's fuel palette),
name + blurb on the left, bold accent price on the right. Cards sit on
clear list rows with their own rounded fill + subtle accent tint.
Purchase flow, per-tier live prices, disabled state and footer copy
unchanged.
2026-08-16 08:57:40 +01:00
FuelBoard Contributor 5060aa9df5 APPSTORE: tip jar checklist — three consumables + IAP capability
Adds the three tip IAPs (tip099/tip299/tip499 at £0.99/£2.99/£4.99) to
the USER-owner ASC tasks, and the In-App Purchase capability to the
App ID blocker alongside the App Group.
2026-08-16 08:37:33 +01:00
FuelBoard Contributor 41e4c913fb Tip jar: three tiers with names and blurbs
Splash & Dash (£0.99), Half a Tank (£2.99), Fill 'Er Up (£4.99) —
each a consumable (com.apt.fuelboard.tip099/tip299/tip499) with its own
blurb row. TipStore now loads all three products, prices fall back to
the intended storefront values until ASC products exist, and the
Transaction.updates listener acknowledges any of the three tiers.
Footer copy: 'A tip helps keep this app free and supports development.
Thank you'.
2026-08-16 08:34:27 +01:00
FuelBoard Contributor 2ffa90d879 Tip jar: finish transactions + Transaction.updates listener
The £4.99 consumable was never finished — StoreKit kept re-delivering
it through Transaction.updates on every launch. Verified purchases now
call transaction.finish() (direct path), and a Transaction.updates
listener finishes + acknowledges purchases that complete outside the
direct call (Ask to Buy approvals, other-device purchases on the same
Apple ID). Untrusted (unverified) transactions are never finished or
delivered; only our own product ID is handled.
2026-08-16 08:23:18 +01:00
FuelBoard Contributor a490bcf7b5 backlog: plan relay+mirror move to dedicated Debian LXC (2026-08-16) 2026-08-15 23:26:56 +01:00
FuelBoard Contributor 29bf2f98d6 Widget shots: use the shipping app icon (light + dark variants)
- AppIconPreview now byte-identical to the real AppIcon.png (the preview
  had been pointing at an older Design/ export); added AppIconPreviewDark
  from AppIcon-dark.png so the mock shows the correct icon per appearance
- Re-captured 9-widgets-dark + 10-widgets-light
- 95/95 tests green; Release IPA rebuilt + re-served
2026-08-15 16:53:31 +01:00
FuelBoard Contributor 015368faaf Real iPhone wallpaper + populated Favourites captures
- PreviewWallpaper image set: the actual iPhone wallpaper extracted from the
  iOS 26.3 sim runtime (ActionButtonSelector Display.ca, 1320x2870 native
  res, JPEG ~180 KB) — replaces the gradient backdrop in the widget mock
- -seedFavourite <substring> launch hook: pins the first matching station
  for Unleaded via the normal keychain+app-group save path, so the
  Favourites tab can be captured populated (Ring Road Service Station,
  129.9, 59.1 mi, TOP badge)
- Re-captured 2-favourites (light), 6-favourites-dark, 9-widgets-dark,
  10-widgets-light with real data
- 95/95 tests green; Release IPA rebuilt + re-served
2026-08-15 16:49:09 +01:00
FuelBoard Contributor 6369eef249 Widget preview: real small-tile proportions + iOS-default-style wallpaper
- Small widget card sized to actual system-small content (158×158pt + padding)
  instead of filling available width; medium keeps 338×158pt
- Wallpaper now mimics the iOS default (dark navy→black in dark mode,
  soft blue-grey in light) via colorScheme-adaptive gradient
- Added 10-widgets-light.png; re-captured 9-widgets-dark.png with data
- 95/95 tests green; Release IPA rebuilt + re-served
2026-08-15 16:32:45 +01:00
FuelBoard Contributor 57121d5213 Widget preview screen + shared widget rendering; dark-mode screenshots with real data
- Move widget entry view into Shared/FuelPriceWidgetViews.swift (FuelPriceWidgetContent,
  FuelPriceWidgetEntryData) so the app can render the REAL widget faces; widget
  extension now wraps it (FuelPriceEntry.data) — one source of truth for the face
- WidgetMockScreen (-widgets launch arg): app icon + small + medium widget previews
  for App Store captures; AppIconPreview image set (1024 drip master)
- AppStoreScreenshots: 5-8 dark-mode tab captures with real Halifax data, 9 widgets
- 95/95 tests green; Release IPA rebuilt + re-served
2026-08-15 16:25:10 +01:00
FuelBoard Contributor 24b6ca9bb2 Copy: England → UK-wide, include Northern Ireland; migrate user-facing copy into Localizable.strings
- Onboarding feature: 'England-wide prices' → 'UK-wide prices — 8,000+ stations, updated twice a day' (key + value unified)
- Stations empty state: 'covers England, Scotland and Wales' → 'covers the whole UK' (dataset includes Northern Ireland)
- Settings Data source footer: source wording updated (CSV mirror or API), OGL attribution retained
- Empty state, browse button and OGL footer now keys in Localizable.strings so the web copy editor covers them
- APPSTORE.md + BACKLOG.md + code comments: England-wide → UK-wide, NI included
2026-08-15 16:04:56 +01:00
FuelBoard Contributor 4797886a1d Add raw App Store screenshot captures (6.9-inch, 1320x2868)
Raw tab captures for the yuzu-hub App Store Screenshot Generator:
stations, favourites, alerts, settings. Captured via simulator launch
args (-skipOnboarding / -tab).
2026-08-15 15:55:19 +01:00
FuelBoard Contributor b5a49fc68d App Store prep: privacy manifests, 1.0.1 (2), OGL attribution, UK empty state, screenshot harness
- PrivacyInfo.xcprivacy in app + widget targets (UserDefaults 1C8F.1;
  app declares Location linked + Diagnostics not-linked, no tracking)
- Version bump 1.0.1 (2) in App + Widget plists
- Settings → Data source footer: OGL attribution + 'refresh up to twice a day'
- Stations empty state: friendly UK-only explainer + 'Browse all UK stations'
- ContentView launch-arg harness (pattern of -showKeySheet): -skipOnboarding
  and -tab stations|favourites|alerts|settings for screenshot capture
- APPSTORE.md updated (blockers struck, tasks done, yuzu-hub workflow)
- BACKLOG.md hygiene: GitHub-first header, Trends merged note, offline-first-run DONE
2026-08-15 15:50:26 +01:00
FuelBoard Contributor 06dd8a919c APPSTORE.md: submission plan — P0 resolved old blockers, owner/timeline checklist 2026-08-15 14:57:20 +01:00
505 changed files with 16323 additions and 1518 deletions
+3
View File
@@ -1,6 +1,9 @@
build/ build/
Builds/
build-*/
build-release/ build-release/
build-sim/ build-sim/
build-watch/
.DS_Store .DS_Store
*.xcuserstate *.xcuserstate
xcuserdata/ xcuserdata/
+216 -71
View File
@@ -1,86 +1,231 @@
# FuelBoard App Store Approval Review # FuelBoard App Store Submission Plan
Review date: 2026-08-11 · Target: App Store submission readiness. Updated: 2026-09-26 · Target: safe App Store submission.
This is the companion to SECURITY.md — focuses on App Store *review* gate Companion to SECURITY.md (code security) — this file is the *review-gate*
(guideline compliance), not code security. checklist: what must happen, who owns it, and the timeline.
## Status: NOT SUBMISSION-READY ## Status: SUBMITTABLE after the checklist below
Two blockers remain before a real submission (see below). Everything else The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
checks out for review. - ~~LAN relay data source~~ → **app fetches from public HTTPS
(raw.githubusercontent.com mirror) with a bundled real 8,022-station dump
as the no-network fallback.** No private IP in the consumer path. Reviewers
on Apple's network see real data.
- ~~Always-location risk~~ → **Always is requested only when the user enables
Alerts** (ProximityMonitor escalates WhenInUse → Always on alert opt-in).
Onboarding requests WhenInUse only. FuelBoard does **not** declare the
persistent real-time background location mode; optional alerts use
CoreLocation region monitoring/significant-change behaviour instead.
## Blockers ## StoreKit sandbox: verified working configuration and caveats
### 1. Data source — LAN relay (BLOCKER) ### Verified configuration
- The app fetches from `http://192.168.1.131:8788` — a private LAN host. Off
the user's network it fails and falls back to sample data. Reviewers on
Apple's network will see empty/sample results → 2.1 (performance) rejection,
and hardcoded IPs look like test builds.
- **Fix**: the govuk API switch (BACKLOG P0). App must talk to a public HTTPS
endpoint before submission. The relay (HTTPS domain) or direct gov.uk OAuth
both work; the code path is already stubbed.
### 2. Always-location justification (BLOCKER without review notes) The FuelBoard consumable tip products are:
- `NSLocationAlwaysAndWhenInUseUsageDescription` + `UIBackgroundModes: location`
present, but guideline 5.1.1 requires the feature to be obvious AND the app
must visibly use background location. The geofenced "cheapest station" alerts
satisfy this, but the reviewer must see it:
- Provide detailed App Review notes explaining the alert flow (enable Alerts →
geofence trigger → notification when approaching the cheapest station).
- Ensure the Alerts tab is discoverable and functional in review.
- Also: "Always" prompt should only appear after the user opts into alerts
(in-progress UX; currently the permission prompt may fire too early).
## Review-relevant configuration — PASS - `com.apt.fuelboard.tip099`
- `com.apt.fuelboard.tip299`
- `com.apt.fuelboard.tip499`
- [x] **App icon**: was MISSING entirely (invalid-binary rejection). Fixed — The app requests these exact **Product IDs** with StoreKit 2
1024×1024 `AppIcon.appiconset` + `ASSETCATALOG_COMPILER_APPICON_NAME`. `Product.products(for:)`; the UI names are display labels and are not used for
- [x] **Launch screen**: `UILaunchScreen` empty dict present (iOS 14+ style). lookup. The app bundle ID, App Store Connect app, explicit App ID, development
- [x] **Device family**: iPhone-only (`TARGETED_DEVICE_FAMILY = 1`) — no iPad team, and Sandbox Apple Account must belong to the same developer account.
multitasking requirements.
- [x] **Versioning**: `CFBundleShortVersionString 1.0`, `CFBundleVersion 1`
(bump per release).
- [x] **Permissions**: only location (WhenInUse + Always), with clear usage
strings. No contacts/photos/mic/camera.
- [x] **No third-party SDKs** → no SDK privacy manifests or third-party
attribution required.
- [x] **No account creation / login** → no account-deletion requirement (5.1.1(v)).
- [x] **ATS**: no `NSAllowsArbitraryLoads` (raw-IP cleartext is ATS-exempt; once
the relay is HTTPS this is moot).
- [x] **Export compliance**: no crypto beyond keychain/SecItem → no
documentation needed.
## Content / IP review Two test environments must remain distinct:
- **Brand logos (Shell, BP, Tesco…)**: bundled from Simple Icons (CC0) + - **Local StoreKit testing:** select `/Users/apt/workspace/fuelboard/FuelBoardTips.storekit`
Wikimedia Commons. Nominative use (identifying stations) is standard practice in Xcode, under Product > Scheme > Edit Scheme > Run > Options. This bypasses
(cf. GasBuddy). Low risk, but keep the sources documented. App Store Connect and confirms the app's StoreKit loading and purchase code.
- **Price data**: gov.uk Fuel Finder data is OGL-licensed — include attribution - **Apple sandbox testing:** select **None** for StoreKit Configuration, then
in the app (About/credits) once live data ships. Sample fallback data must be use Product > Run on a physical device with the development-signed build and
clearly demo-labelled (or removed for review) so reviewers don't think the a Sandbox Apple Account signed in under Settings > Developer. This exercises
prices are fake — 2.3.1 accuracy risk. the real App Store Connect catalog.
- **"Cheapest" claims**: data is 12h-old cache. The UI says "cheapest" — fine,
but a small "prices from the UK Fuel Finder dataset, updated twice daily"
footnote is good hygiene for 2.3.1.
## Suggested review notes (copy-paste for App Store Connect) The local configuration returning all three products does not prove that the
Apple sandbox catalog is ready. Conversely, a successful local run showing `$`
does not mean the App Store Connect price is USD: `Product.displayPrice` is
formatted for the active StoreKit/device storefront. Local StoreKit prices are
placeholders and do not upload to App Store Connect.
### Availability versus pricing
App Store Connect has separate controls:
- **Availability** controls the storefronts where the IAP is offered. The
verified FuelBoard configuration uses **United Kingdom only**.
- **Price schedule** may show generated prices for **175 countries and
regions**. This is expected and does not make the IAP available in all 175
storefronts; it is Apple's price conversion schedule.
The Sandbox Apple Account's App Store Country or Region must match an enabled
IAP storefront. After changing the sandbox tester's storefront, sign out and
back in under Settings > Developer so the storefront change activates.
### Important propagation/cache caveat
During sandbox diagnosis, `Product.products(for:)` completed successfully but
returned zero products with the IAP availability set to UK. Temporarily
changing one product's availability to all regions caused it to appear in the
sandbox catalog; changing it back to UK left it working. This indicates an
Apple-side catalog refresh or propagation/cache issue, not a change required in
the StoreKit code. Apple documents that product metadata changes can take up to
approximately one hour to appear in sandbox.
If an otherwise valid product returns zero products:
1. Confirm the Sandbox Apple Account belongs to the same developer team.
2. Confirm its storefront is an enabled availability region.
3. Sign out and back in to the sandbox account on the device.
4. Delete/reinstall the development build and retry with StoreKit Configuration
set to **None**.
5. If necessary, temporarily broaden one product's availability to all
regions, allow propagation, verify the product, then restore the intended
availability and allow propagation again.
Record whether the diagnostic result is **request failed**, **completed with
zero products**, or **a partial product set**. These are different failure
classes. Do not add fallback prices or change product lookup to display names.
Sandbox testing does not require prior App Review approval of the IAP. It does
require active developer membership, an active Paid Applications Agreement,
complete banking and tax information, valid product metadata and pricing, the
correct app/team association, and a matching Sandbox Apple Account.
## Hard blockers (must happen before ANY submission)
0. **[USER] Paid Applications Agreement in effect** — REQUIRED for paid IAP
configuration and sandbox testing. App Store Connect →
Business > Paid Applications Agreement → View and Agree. **This is the most
common cause of a 2.1(b) "unable to purchase item / error message" rejection
— without it every sandbox purchase fails.** (REJECTED once for this:
review date 2026-09-25, version 1.0 (21), iPad Air 11″ iPadOS 27.)
**Status 2026-09-25: agreement clicked Agree but shows "Pending user info" —
this is NOT in effect yet.** Complete the Banking (account + sort code/IBAN)
and Tax (W-8/W-9/UK tax ref, e.g. sole-trader UTR) sections under Business >
Payments & Tax until the agreement shows "Active" and every tab is green.
Only then do sandbox IAPs start working.
0. **[USER] Tip IAPs configured and available to the sandbox** — the three
consumables may exist but must ALSO have complete metadata, a current price,
and an enabled storefront availability. Prior App Review approval or
`Cleared for Sale` status is **not required for sandbox product discovery**.
They must be backed by the **In-App Purchase capability on the App ID**
`com.apt.fuelboard` (developer portal → Identifiers → App ID capability).
3-tier tip code is already correct (retry lookup `7cb23e0`); do NOT churn
SettingsView.swift. Test product discovery and purchase with a Sandbox
Apple Account first: Settings → Support FuelBoard.
1. **[USER] Apple Developer Program membership** — the app has never been
signed (all builds are `CODE_SIGNING_ALLOWED=NO` + ad-hoc re-sign for
SideStore). Requires a paid membership ($99/yr, up to 48 h to approve) and
a developer account.
2. **[USER] Register App ID `com.apt.fuelboard`** in the developer portal,
with the **App Group `group.com.apt.fuelboard`** capability AND the
**In-App Purchase** capability enabled (the widget + app-group cache
depend on the group; the tip jar needs IAP).
3. ~~[ME] `PrivacyInfo.xcprivacy`~~ — **DONE** (app, widget, watch app, and
watch wrapper manifests added; UserDefaults `1C8F.1`; app declares Location).
4. **[ME] Real Distribution signing** — Distribution certificate + App Store
provisioning profile (or Xcode automatic signing once the account is in
Xcode); then `xcodebuild archive` (no more `CODE_SIGNING_ALLOWED=NO`).
5. **[USER] Privacy policy URL** in App Store Connect — mandatory; the app
collects location. Policy text is now in `PRIVACY.md`; publish it via the
FuelBoard marketing site or GitHub Pages and paste that public URL into App
Store Connect.
## Required tasks — owner [ME] (technical, do after account exists)
- [x] **PrivacyInfo.xcprivacy** — app, widget, watch app, and watch wrapper
manifests added: UserDefaults reason `1C8F.1`; app also declares Location
(linked, not tracking). Diagnostic network beacon removed with LAN relay.
- [ ] Distribution archive + TestFlight upload (needs Apple account)
- [x] **Version/build aligned** → `1.0.0` (build 23), app + widget + watch targets
- [x] **OGL attribution** — Settings → Data source footer now reads "Prices
use the UK Fuel Finder dataset (Open Government Licence) and refresh
up to twice a day." (2.3.1 hygiene)
- [x] **Out-of-UK empty state** — "No X stations within N miles" + UK-only
explainer + "Browse all UK stations" button (switches to Closest)
- [x] **Remove LAN relay fallback entirely** — no private IP, Local Network
permission string, relay toggle, or diagnostic network beacon remains in
the app/widget/watch binary. Hidden Debug still exists for local notification
and widget diagnostics only.
- [ ] Final pass: `swift test` green (95 tests), archive smoke test on device
- [ ] ~~Capture screenshots from simulator~~ **RAW CAPTURES DONE (2026-08-16)** —
native 6.9″ 1320×2868 PNGs of all four tabs (Stations, Favourites,
Alerts, Settings) at `/tmp/fuelboard-shots/asc/`; assembled into ASC
frames by the user in the yuzu-hub App Store Screenshot Generator
(https://yuzu-hub.github.io/appscreen/). Screenshot harness: launch
args `-skipOnboarding` / `-tab stations|favourites|alerts|settings`
(ContentView, same pattern as `-showKeySheet`).
## Required tasks — owner [USER] (App Store Connect, cannot be done by me)
- [ ] Apple Developer Program membership + App ID (blockers #1–#2)
- [ ] **Tip jar: create the three consumable IAPs** in App Store Connect →
In-App Purchases (all type **Consumable**, exact product IDs, UK price
tier; tip jars are an accepted App Review pattern):
- `com.apt.fuelboard.tip099` — **£0.99** — "Splash & Dash" (Just enough to keep things moving.)
- `com.apt.fuelboard.tip299` — **£2.99** — "Half a Tank" (A generous top-up for development.)
- `com.apt.fuelboard.tip499` — **£4.99** — "Fill 'Er Up" (Keeping the app on the road.)
App-side is ready (`41e4c91`): Settings → Support FuelBoard shows all
three tiers with per-tier prices; sideloaded builds gracefully show
"not available in this build yet" until the products exist.
- [ ] App Store Connect app record: name, bundle ID `com.apt.fuelboard`,
category (Utilities), pricing (Free), availability (UK first?)
- [ ] Description, keywords, support URL, marketing URL, privacy policy URL
- [ ] Privacy nutrition labels questionnaire (location: yes, linked to user
for alerts/nearby; no tracking; no ATT prompt needed)
- [ ] Age rating questionnaire (4+; no objectionable content)
- [ ] Export compliance: HTTPS-only + keychain → standard encryption
exemption (5A992a), no documentation needed
- [ ] Approve the TestFlight build and do a real-device pass before submit
- [ ] Final "Submit for Review" click + stand by to answer review questions
## Review notes (copy-paste into App Store Connect)
> FuelBoard shows the cheapest petrol, diesel, and premium fuel near the user > FuelBoard shows the cheapest petrol, diesel, and premium fuel near the user
> using the UK government Fuel Finder dataset (OGL). Prices are fetched up to > using the UK government Fuel Finder dataset (Open Government Licence).
> twice a day; the app filters 5/10/15-mile radii on-device. Location is used > Prices are downloaded from the internet up to twice a day; the app filters
> to (a) rank stations by distance and (b) power the optional Alerts feature: > 5/10/15-mile radii on-device and works offline from a bundled snapshot.
> when enabled, the app geofences the cheapest station in the user's chosen > Location is used to (a) rank stations by distance and (b) power the
> radius and sends a notification when they approach it. Always-location is > optional Alerts feature: when the user enables alerts, the app registers
> only requested when the user enables alerts. The app also provides a > CoreLocation regions around the cheapest station in their radius and sends
> home-screen widget showing the current cheapest station nearby. > a notification when they approach it. FuelBoard does not use persistent
> real-time background location tracking; it relies on iOS region monitoring
> and significant-change behaviour for optional alerts. Always-location is
> requested only when the user enables alerts. The app also provides a
> home-screen widget showing the current cheapest station nearby, and a
> price-history Trends chart for starred stations. All data is UK-wide (Fuel
> Finder covers England, Scotland, Wales and Northern Ireland); users outside
> the UK see an empty state explaining this.
## Release checklist (before every submission) ## Review risks (know before submitting)
1. [ ] Relay/govuk data source live on a public HTTPS endpoint - **Always-location (5.1.1 / 2.5.4)** — the feature is visible (Alerts tab)
2. [ ] Always-permission flow tied to explicit user opt-in and opt-in; the app does not declare persistent background location mode,
3. [ ] Bump `CFBundleShortVersionString` / `CFBundleVersion` and the review notes describe region monitoring/significant-change rather
4. [ ] Sample-data fallback labelled or removed in the release build than continuous tracking.
5. [ ] OGL attribution present in-app - **Reviewer outside the UK** — empty radius; mitigated by the empty-state
6. [ ] `swift test` green (FuelBoardTests, 28 tests) task above + review notes.
7. [ ] Security audit pass (see SECURITY.md recurring checks) - **"Cheapest" claims (2.3.1)** — mitigated by the OGL/footnote task.
8. [ ] Icon + screenshots for all required sizes in App Store Connect - **Live Activities** — reviewers may ask why; answer: price alerts at a
glance. It is declared in the plist already.
- **Brand logos (Shell, BP, Tesco…)** — Simple Icons (CC0) + Wikimedia,
nominative use (cf. GasBuddy); low risk, keep sources documented.
- **GitHub raw as data source** — public HTTPS data, no credentials or Local
Network permission; bundled dump covers any regional GitHub unreachability.
## Timeline (best case)
| Day | Owner | Milestone |
|-----|-------|-----------|
| 0–2 | USER | Developer Program enrollment + App ID + app group capability |
| 2–3 | ME | Signing (automatic), PrivacyInfo.xcprivacy, attribution, empty state, version bump |
| 3–4 | ME | Archive → TestFlight; screenshots captured |
| 4–5 | USER | TestFlight install + real-device pass (onboarding, alerts, widget, offline) |
| 5–6 | USER | App Store Connect metadata + labels + age rating + privacy URL |
| 6–7 | USER/ME | Submit for review |
| 7–14 | Apple | Review (typically 1–3 days, can be a week); answer questions same-day |
Rollback note: nothing about submission changes the repo's rollback posture
(`main` history is intact; release builds are tagged per submission).
Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

+114 -22
View File
@@ -1,8 +1,10 @@
# FuelBoard Backlog # FuelBoard Backlog
FuelBoard — England-wide petrol-station price widget + app. FuelBoard — UK-wide petrol-station price widget + app.
Relay mirrors the UK gov data (hourly CSV); app fetches the full-UK dump GitHub mirror (`aptonline/fuelboard-data`, 2×/day 10:00+16:00) holds the
(~8,038 stations, all prices) once per 12 h and filters/sorts on-device. full-UK dump (~8,022 stations, all prices); app fetches it once per 12 h and
filters/sorts on-device, with a bundled snapshot as the no-network last resort.
LAN relay fallback has been removed from the app binary; the public GitHub mirror is the only live network data source.
Priority guide: P0 = next build, P1 = soon, P2 = later, P3 = maybe/idea. Priority guide: P0 = next build, P1 = soon, P2 = later, P3 = maybe/idea.
Status: TODO / IN PROGRESS / DONE / BLOCKED. Status: TODO / IN PROGRESS / DONE / BLOCKED.
@@ -11,13 +13,37 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
## P1 — Soon ## P1 — Soon
- [x] **Move relay + mirror off the Mac mini (DONE 2026-08-16)** — dedicated
Debian 13 LXC on Proxmox via community-scripts/ProxmoxVE `ct/debian.sh`
(CT **111**, hostname **`fuelboard-relay`**, 1 core/512 MB/2 GB, static IP
**192.168.1.113**, plain LXC — NO Docker layer). Scope: relay AND mirror
both moved (full Mac independence; relays bind **loopback only**, GitHub is
the only public surface). What landed: Python 3.13.5 (trixie) boots both
relays (relay 8788 demo/CSV + relay-api 8789 `source: api`, 8,024 stations,
~2 min first sync, 0 failures); source at `/root/workspace/fuelboard-{relay,relay-api}`
(venvs + pip, `.env` chmod 600); deploy key `fuelboard_deploy` +
`github-fuelboard-data` ssh config + `fuelboard-data` clone at
`/root/workspace/fuelboard-data` (origin = ssh alias, green + synced);
`mirror_push.py` → `/root/scripts/mirror_push.py` (RELAY line already
`127.0.0.1:8789`); systemd `fuelboard-relay.service` +
`fuelboard-relay-api.service` (Restart=always, `--host 127.0.0.1`) +
`fuelboard-mirror.service` (oneshot) + `.timer` (10:00 + 16:00, Persistent,
armed — next Mon 10:00); first live push VERIFIED from outside
(commit `735a292`, latest.json `data_updated 16:50:53Z`); Mac launchd
`com.apt.fuelboard-{relay,relay-api,mirror}` unloaded + plists parked in
`~/Library/LaunchAgents/fuelboard-migrated-to-ct/`; editor 8790 + IPA 8765
stay on the Mac; price watchdog `relay_price_watchdog.py` now fetches via
SSH (`~/.ssh/fuelboard_ct` → `/root/scripts/watchdog_fetch.py`). Logs via
`journalctl -u fuelboard-*` on the CT; SSH into CT: `ssh -i ~/.ssh/fuelboard_ct root@192.168.1.113`.
**Open: rootfs is 2 GB (1.1 GB free) — mirror retention ~940 MB will get
tight; rootfs grown to 8 GB on pve 2026-08-16 (5.9 GB free).**
- [x] **Siri: "Cheapest [fuel] near me"** — `AppShortcutsProvider` + `CheapestFuelIntent` - [x] **Siri: "Cheapest [fuel] near me"** — `AppShortcutsProvider` + `CheapestFuelIntent`
(App Intents, iOS 16+), dialog + price-card snippet, cached full-UK dump first (App Intents, iOS 16+), dialog + price-card snippet, cached full-UK dump first
with freshness label ("as of 6am"), last-known-location fallback. Constraint: with freshness label ("as of 6am"), last-known-location fallback. Constraint:
fresh-data path needs a reachable source off-LAN — cached answers work anywhere fresh-data path needs a reachable source off-LAN — cached answers work anywhere
the app has previously fetched (see P3 data mirror). Interim: the relay on the app has previously fetched (the GitHub mirror serves this now). *DONE —
Tailscale (100.120.217.98:8789) is reachable off-LAN for testing; relay will radius-scoped
move to a permanent box or the GitHub mirror long-term. *DONE — radius-scoped
lookup `037c241` (real-data regression: GULF HISTON 100.9p 132 mi away), lookup `037c241` (real-data regression: GULF HISTON 100.9p 132 mi away),
per-fuel literal-word shortcuts `e55078c`, names = app words `a3b6f37`, plus per-fuel literal-word shortcuts `e55078c`, names = app words `a3b6f37`, plus
Directions-to-cheapest `caadf18` + spoken hand-off `aed99e3`.* Directions-to-cheapest `caadf18` + spoken hand-off `aed99e3`.*
@@ -31,7 +57,7 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
capsule fuel picker; range control 7/30/90 days; respects the price-display capsule fuel picker; range control 7/30/90 days; respects the price-display
toggle; honest empty-state copy in Localizable.strings ("Prices are recorded from toggle; honest empty-state copy in Localizable.strings ("Prices are recorded from
each refresh — check back in a few days"). No widget in v1 (one-kind rule). each refresh — check back in a few days"). No widget in v1 (one-kind rule).
Depends on the P0 archive. *DONE 2026-08-15 on feature/trends-history (unmerged): Depends on the P0 archive. *DONE 2026-08-15, merged to main 2026-08-16:
`Shared/FuelHistory.swift` (FuelHistoryStore: day math, slim day decode w/ shared `Shared/FuelHistory.swift` (FuelHistoryStore: day math, slim day decode w/ shared
band, series + deltaSeries, app-group favourites-only cache, parallel per-day band, series + deltaSeries, app-group favourites-only cache, parallel per-day
fetches) + `FuelBoard/TrendsView.swift` (chart, fuel capsule, 7/30/90, Price/vs fetches) + `FuelBoard/TrendsView.swift` (chart, fuel capsule, 7/30/90, Price/vs
@@ -39,8 +65,8 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
FavouritesView + strings. 83 tests incl. URL regression (appendingPathComponent FavouritesView + strings. 83 tests incl. URL regression (appendingPathComponent
— URL(string:relativeTo:) dropped /main, all fetches 404'd). Sim-verified: — URL(string:relativeTo:) dropped /main, all fetches 404'd). Sim-verified:
favourites rows + TOP/deltas; sheet controls + honest empty state w/ live first- favourites rows + TOP/deltas; sheet controls + honest empty state w/ live first-
snapshot date. Pending: merge to main after device sideload test; lines render snapshot date. Pending: lines render once ≥2 snapshots (archive warming daily,
once ≥2 snapshots (archive warming daily).* ~Aug 17).*
## P2 — Later ## P2 — Later
@@ -80,10 +106,42 @@ 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
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:
`Minimum change` picker (e.g. 0.5p / 1.0p / 2.0p), plus a `Check now`
action in the Alerts tab that forces a fetch and immediately re-evaluates the
cheapest-favourite alert. Copy should set expectation honestly: standard
mode checks when FuelBoard refreshes prices (up to twice daily).
- [ ] **Price-drop alerts: optional frequent-check mode** — if the app ever
relaxes the current "twice daily max" data policy, expose this as a clearly
separate mode rather than a freeform frequency slider. Example framing:
`Standard` (current behaviour) vs `Frequent` (best-effort extra checks),
with conservative caps (e.g. every 4–6 h, not hourly) and copy that does not
promise exact timing under iOS background scheduling.
- [ ] **Pull-to-refresh spinner state** — surface refresh in-flight state - [ ] **Pull-to-refresh spinner state** — surface refresh in-flight state
(currently `refreshable` fires but no visible progress in the row list). (currently `refreshable` fires but no visible progress in the row list).
- [ ] **Offline first-run** — first launch without network currently shows empty - [x] **Offline first-run** — bundled REAL 8,022-station dump (`FuelBoardDump`,
states; bundle a sample dump so the app demos offline. refreshed by `scripts/refresh_bundled_dump.sh`) is the no-network last resort
in the live chain (P0, 2026-08-16); first launch demos the full dataset offline.
- [ ] **Localization** — UK English only today; audit string catalog for l10n. - [ ] **Localization** — UK English only today; audit string catalog for l10n.
- [ ] **Dark mode polish** — verify brand logos (white-circle clips) and RAG dot - [ ] **Dark mode polish** — verify brand logos (white-circle clips) and RAG dot
contrast under dark appearance on-device. contrast under dark appearance on-device.
@@ -104,13 +162,50 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
separate phase. Backlog ancestry: this was the original "relay could keep a separate phase. Backlog ancestry: this was the original "relay could keep a
rolling archive" idea — the archive is now P0 and the Trends graph (P1) is its rolling archive" idea — the archive is now P0 and the Trends graph (P1) is its
visible half. visible half.
- [ ] **Foresight Live Activity (heading-aware next station)** — investigate a
driving-time surface that predicts the likely next station ahead of the user
from cached data only (no extra source-data refresh on motion). Best v1 shape:
Live Activity option, not widget-first, because widget space/refresh budgets
are tight and the lock-screen/CarPlay compact slot better matches the use case.
Prefer ONE FuelBoard Live Activity with a mode selector (e.g. `Nearby cheapest`
vs `Foresight / next ahead`) rather than trying to run two simultaneous
FuelBoard activities; iOS can support multiple activities, but FuelBoard's
current implementation is single-activity and the compact surfaces are too
constrained for two competing driving modes anyway. Viable MVP: infer heading
from recent motion/course, keep only stations in a forward cone, score
candidates by forward distance + lateral offset, and show the next-ahead
station with all three fuel prices or a compact rotating/focused fuel
treatment if space allows. Key caveat: heading is not full route awareness,
so avoid overpromising "the next station you will reach" on motorways/opposite
carriageways; frame as "next ahead" / "likely next stop" unless route-quality
heuristics improve. Must include hysteresis/speed thresholds to avoid flapping,
and should be tested specifically against compact Live Activity/CarPlay layouts
where space is extremely limited.
- [ ] **Widget sparkline** — medium-widget mini sparkline of a favourite's 30-day - [ ] **Widget sparkline** — medium-widget mini sparkline of a favourite's 30-day
trend, after the Trends UI lands (one-kind rule preserved). trend, after the Trends UI lands (one-kind rule preserved).
- [ ] **Price alerts for a specific station** — notify when a starred station's - [ ] **Price alerts for a specific station** — notify when a starred station's
price drops below a user threshold. price drops below a user threshold.
- [ ] **Price-drop alerts: mode = Cheapest favourite / All favourites** — keep
`Cheapest favourite` as the default low-noise mode, but explore an optional
`All favourites` mode for one fuel. Guardrails if built: threshold required,
per-station dedupe based on last-alerted price (not just time), and combine
multiple drops from one refresh into a single summary notification rather
than one alert per station.
- [ ] **Share sheet** — share "Cheapest Unleaded near me" as text/image card. - [ ] **Share sheet** — share "Cheapest Unleaded near me" as text/image card.
- [ ] **Watch app** — glanceable cheapest-price complication (WatchKit + shared - [ ] **Watch app v1 — favourites + Check now** — small Apple Watch companion,
app-group store; watch target would need to be added to the project). not a phone-app clone. Favourites-first home screen: fuel picker, `Cheapest
favourite` summary card, top few favourites for that fuel, last-updated time,
and a prominent `Check now` action that forces a refresh + re-evaluates
favourite prices. Keep the watch UX pull-based and glanceable; avoid implying
high-frequency background refresh. Shared-store architecture preferred over a
second watch-only data model.
- [ ] **Watch complications / Smart Stack** — complication/widget surfaces for
the same watch concept: show the cheapest favourite price, fuel, and short
station name at a glance. Keep copy minimal and honest about freshness.
- [ ] **Watch app v2 — favourite detail + handoff** — tap a favourite row for a
detail screen (station, brand, price, last updated) with actions like
`Directions` and `Open on iPhone`. Reuse existing maps handoff patterns where
possible instead of inventing watch-only navigation logic.
- [ ] **App Clip** — 10 MB budget for "find cheapest fuel nearby" without install. - [ ] **App Clip** — 10 MB budget for "find cheapest fuel nearby" without install.
- [ ] **Widget picker: dual-unit distance labels** — the Edit-Widget Distance row - [ ] **Widget picker: dual-unit distance labels** — the Edit-Widget Distance row
label is cached by the system at sheet-render time and only re-resolves on label is cached by the system at sheet-render time and only re-resolves on
@@ -134,15 +229,12 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
deploy key, 180-day retention) + history read side (Trends, P1) + **live chain deploy key, 180-day retention) + history read side (Trends, P1) + **live chain
(`feature/p0-live-chain`)**: `MirrorFuelProvider` fetches CURRENT prices from (`feature/p0-live-chain`)**: `MirrorFuelProvider` fetches CURRENT prices from
raw.githubusercontent (latest.json → history/<day>.json, app-group day-cache) → raw.githubusercontent (latest.json → history/<day>.json, app-group day-cache) →
**GitHub FIRST everywhere** (alert checks + widget too — the relay is **GitHub mirror only everywhere** (alert checks + widget too); no LAN relay
unreachable from any consumer phone and attempting it would fire the Local fallback, private IP, Local Network permission, or diagnostic network beacon
Network prompt); LAN relay joins ONLY behind the hidden dev flag ships in the app binary. Bundled REAL dump (`FuelBoardDump` dataset, refreshed
`fuelboard.relayFallback` (Settings → Debug, off by default); bundled REAL by `scripts/refresh_bundled_dump.sh`) is the app's no-network last resort.
dump (`FuelBoardDump` dataset, refreshed by `scripts/refresh_bundled_dump.sh`) Local Network permission removed entirely — data page is informational, replay
is the app's no-network last resort. `FuelBeacon` fire-and-forget X-Client app on cellular can no longer hang. 107+ tests.
ping fires only with the dev flag on. **Local Network permission removed from
onboarding entirely** — data page is informational, replay on cellular can no
longer hang. 95 tests.
- [x] Switch dataset to govuk API data — official Fuel Finder OAuth API via - [x] Switch dataset to govuk API data — official Fuel Finder OAuth API via
relay-api :8789 (source: api, ~8,012 stations); app baseURL → :8789; credentials relay-api :8789 (source: api, ~8,012 stations); app baseURL → :8789; credentials
live in relay `.env` only, never in the IPA live in relay `.env` only, never in the IPA
+10 -8
View File
@@ -16,6 +16,8 @@
<string>$(PRODUCT_NAME)</string> <string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>
@@ -27,23 +29,23 @@
</array> </array>
</dict> </dict>
</array> </array>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSLocationWhenInUseUsageDescription</key>
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key> <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>FuelBoard uses Always location to alert you when you approach the cheapest station nearby.</string> <string>FuelBoard uses Always location to alert you when you approach the cheapest station nearby.</string>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>FuelBoard can optionally connect to the FuelBoard Relay on your local network for faster alerts during development.</string> <string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
<key>NSSupportsLiveActivities</key> <key>NSSupportsLiveActivities</key>
<true/> <true/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.apt.fuelboard.smart-refresh</string>
</array>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>location</string> <string>fetch</string>
</array> </array>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
<dict/> <dict/>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.apt.fuelboard</string>
</array>
</dict>
</plist>
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>FuelBoardWatch</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>WKAppBundleIdentifier</key>
<string>com.apt.fuelboard.watchapp</string>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.watchkit</string>
</dict>
</dict>
</plist>
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>FuelBoard</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>FuelBoard</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>WKCompanionAppBundleIdentifier</key>
<string>com.apt.fuelboard</string>
<key>WKWatchKitApp</key>
<true/>
</dict>
</plist>
+4 -4
View File
@@ -17,15 +17,15 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>FuelBoard uses your location to show the cheapest nearby petrol stations.</string>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string> <string>com.apple.widgetkit-extension</string>
</dict> </dict>
<key>NSLocationWhenInUseUsageDescription</key>
<string>FuelBoard uses your location to show the cheapest nearby petrol stations.</string>
</dict> </dict>
</plist> </plist>
+649 -34
View File
@@ -1,7 +1,4 @@
// !$*UTF8*$! // !$*UTF8*$!
// FuelBoard — hand-crafted project (objectVersion 77, synchronized folders).
// Targets: FuelBoard (app) + FuelBoardWidgets (widget extension, embedded).
// Shared/ is a synchronized root group included in BOTH targets.
{ {
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
@@ -10,10 +7,37 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
ADCE5B5A3033A32C008C131C /* FuelBoardWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
BB0000000000000000000001 /* FuelBoardWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* FuelBoardWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; BB0000000000000000000001 /* FuelBoardWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* FuelBoardWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
FBA000000000000000000001 /* DataRefreshMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000101 /* DataRefreshMode.swift */; };
FBA000000000000000000002 /* FuelHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000102 /* FuelHistory.swift */; };
FBA000000000000000000003 /* FuelPriceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000103 /* FuelPriceProvider.swift */; };
FBA000000000000000000004 /* FuelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000104 /* FuelStore.swift */; };
FBA000000000000000000005 /* MirrorFuelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000105 /* MirrorFuelProvider.swift */; };
FBA000000000000000000006 /* SiriCheapestLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000106 /* SiriCheapestLookup.swift */; };
FBA000000000000000000007 /* WidgetLocationFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000107 /* WidgetLocationFetcher.swift */; };
FBA000000000000000000008 /* FuelBoardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000108 /* FuelBoardTests.swift */; };
FBA000000000000000000009 /* FuelHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000109 /* FuelHistoryTests.swift */; };
FBA00000000000000000000A /* LiveChainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA00000000000000000010A /* LiveChainTests.swift */; };
FBA00000000000000000000B /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FBA00000000000000000010B /* XCTest.framework */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
ADCE5B643033A32D008C131C /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = BB0000000000000000000004 /* Project object */;
proxyType = 1;
remoteGlobalIDString = ADCE5B4E3033A329008C131C;
remoteInfo = "FuelBoardWatch Watch App Extension";
};
ADCE5B6F3033A32F008C131C /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = BB0000000000000000000004 /* Project object */;
proxyType = 1;
remoteGlobalIDString = ADCE5B673033A32D008C131C;
remoteInfo = "FuelBoardWatch Watch App";
};
BB0000000000000000000003 /* PBXContainerItemProxy */ = { BB0000000000000000000003 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = BB0000000000000000000004 /* Project object */; containerPortal = BB0000000000000000000004 /* Project object */;
@@ -24,6 +48,28 @@
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
ADCE5B5B3033A32C008C131C /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16;
files = (
ADCE5B5A3033A32C008C131C /* FuelBoardWatch Watch App.app in Embed Watch Content */,
);
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
};
ADCE5B653033A32D008C131C /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */,
);
name = "Embed App Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
BB0000000000000000000006 /* Embed App Extensions */ = { BB0000000000000000000006 /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase; isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -38,11 +84,36 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
AD22FCFF30682512009859D4 /* FuelBoardTips.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = FuelBoardTips.storekit; sourceTree = "<group>"; };
AD64FC4C30739A4B008F27A9 /* FuelBoardSharedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FuelBoardSharedTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FuelBoardWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "FuelBoardWatch Watch App Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
BB0000000000000000000002 /* FuelBoardWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FuelBoardWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; BB0000000000000000000002 /* FuelBoardWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FuelBoardWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
BB0000000000000000000007 /* FuelBoard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FuelBoard.app; sourceTree = BUILT_PRODUCTS_DIR; }; BB0000000000000000000007 /* FuelBoard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FuelBoard.app; sourceTree = BUILT_PRODUCTS_DIR; };
FBA000000000000000000101 /* DataRefreshMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataRefreshMode.swift; sourceTree = "<group>"; };
FBA000000000000000000102 /* FuelHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelHistory.swift; sourceTree = "<group>"; };
FBA000000000000000000103 /* FuelPriceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelPriceProvider.swift; sourceTree = "<group>"; };
FBA000000000000000000104 /* FuelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelStore.swift; sourceTree = "<group>"; };
FBA000000000000000000105 /* MirrorFuelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MirrorFuelProvider.swift; sourceTree = "<group>"; };
FBA000000000000000000106 /* SiriCheapestLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiriCheapestLookup.swift; sourceTree = "<group>"; };
FBA000000000000000000107 /* WidgetLocationFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetLocationFetcher.swift; sourceTree = "<group>"; };
FBA000000000000000000108 /* FuelBoardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelBoardTests.swift; sourceTree = "<group>"; };
FBA000000000000000000109 /* FuelHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelHistoryTests.swift; sourceTree = "<group>"; };
FBA00000000000000000010A /* LiveChainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveChainTests.swift; sourceTree = "<group>"; };
FBA00000000000000000010B /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = System/Library/Frameworks/XCTest.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "FuelBoardWatch Watch App";
sourceTree = "<group>";
};
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = "FuelBoardWatch Wrapper";
sourceTree = "<group>";
};
BB0000000000000000000008 /* FuelBoard */ = { BB0000000000000000000008 /* FuelBoard */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
path = FuelBoard; path = FuelBoard;
@@ -61,6 +132,20 @@
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
ADCE5B4C3033A329008C131C /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
ADCE5B6D3033A32E008C131C /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
BB000000000000000000000B /* Frameworks */ = { BB000000000000000000000B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -75,16 +160,37 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
FBA000000000000000000302 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
FBA00000000000000000000B /* XCTest.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
AD64FC4B30739A4B008F27A9 /* Recovered References */ = {
isa = PBXGroup;
children = (
FBA00000000000000000010B /* XCTest.framework */,
);
name = "Recovered References";
sourceTree = "<group>";
};
BB000000000000000000000D = { BB000000000000000000000D = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
FBA000000000000000000200 /* FuelBoardTests */,
AD22FCFF30682512009859D4 /* FuelBoardTips.storekit */,
BB0000000000000000000008 /* FuelBoard */, BB0000000000000000000008 /* FuelBoard */,
BB0000000000000000000009 /* FuelBoardWidgets */, BB0000000000000000000009 /* FuelBoardWidgets */,
BB000000000000000000000A /* Shared */, BB000000000000000000000A /* Shared */,
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */,
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
BB000000000000000000000E /* Products */, BB000000000000000000000E /* Products */,
AD64FC4B30739A4B008F27A9 /* Recovered References */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -93,37 +199,95 @@
children = ( children = (
BB0000000000000000000007 /* FuelBoard.app */, BB0000000000000000000007 /* FuelBoard.app */,
BB0000000000000000000002 /* FuelBoardWidgets.appex */, BB0000000000000000000002 /* FuelBoardWidgets.appex */,
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */,
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */,
AD64FC4C30739A4B008F27A9 /* FuelBoardSharedTests.xctest */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
FBA000000000000000000200 /* FuelBoardTests */ = {
isa = PBXGroup;
children = (
FBA000000000000000000201 /* Sources */,
FBA000000000000000000202 /* Tests */,
);
path = FuelBoardTests;
sourceTree = "<group>";
};
FBA000000000000000000201 /* Sources */ = {
isa = PBXGroup;
children = (
FBA000000000000000000101 /* DataRefreshMode.swift */,
FBA000000000000000000102 /* FuelHistory.swift */,
FBA000000000000000000103 /* FuelPriceProvider.swift */,
FBA000000000000000000104 /* FuelStore.swift */,
FBA000000000000000000105 /* MirrorFuelProvider.swift */,
FBA000000000000000000106 /* SiriCheapestLookup.swift */,
FBA000000000000000000107 /* WidgetLocationFetcher.swift */,
);
name = Sources;
path = Sources/FuelBoardShared;
sourceTree = "<group>";
};
FBA000000000000000000202 /* Tests */ = {
isa = PBXGroup;
children = (
FBA000000000000000000108 /* FuelBoardTests.swift */,
FBA000000000000000000109 /* FuelHistoryTests.swift */,
FBA00000000000000000010A /* LiveChainTests.swift */,
);
name = Tests;
path = Tests/FuelBoardSharedTests;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
BB000000000000000000000F /* FuelBoard */ = { ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = BB0000000000000000000010 /* Build configuration list for PBXNativeTarget "FuelBoard" */; buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App Extension" */;
buildPhases = ( buildPhases = (
BB0000000000000000000011 /* Sources */, ADCE5B4B3033A329008C131C /* Sources */,
BB000000000000000000000B /* Frameworks */, ADCE5B4C3033A329008C131C /* Frameworks */,
BB0000000000000000000012 /* Resources */, ADCE5B4D3033A329008C131C /* Resources */,
BB0000000000000000000006 /* Embed App Extensions */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
BB0000000000000000000013 /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
BB0000000000000000000008 /* FuelBoard */, ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
BB000000000000000000000A /* Shared */,
); );
name = FuelBoard; name = "FuelBoardWatch Watch App Extension";
packageProductDependencies = ( packageProductDependencies = (
); );
productName = FuelBoard; productName = "FuelBoardWatch Watch App Extension";
productReference = BB0000000000000000000007 /* FuelBoard.app */; productReference = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.watchkit2-extension";
};
ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */ = {
isa = PBXNativeTarget;
buildConfigurationList = ADCE5B6A3033A32D008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */;
buildPhases = (
ADCE5B6E3033A32E008C131C /* Resources */,
ADCE5B653033A32D008C131C /* Embed App Extensions */,
ADCE5B6D3033A32E008C131C /* Frameworks */,
);
buildRules = (
);
dependencies = (
ADCE5B683033A32D008C131C /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */,
);
name = "FuelBoardWatch Watch App";
packageProductDependencies = (
);
productName = "FuelBoardWatch Watch App";
productReference = ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */;
productType = "com.apple.product-type.application.watchapp2";
}; };
BB0000000000000000000005 /* FuelBoardWidgets */ = { BB0000000000000000000005 /* FuelBoardWidgets */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
@@ -148,6 +312,50 @@
productReference = BB0000000000000000000002 /* FuelBoardWidgets.appex */; productReference = BB0000000000000000000002 /* FuelBoardWidgets.appex */;
productType = "com.apple.product-type.app-extension"; productType = "com.apple.product-type.app-extension";
}; };
BB000000000000000000000F /* FuelBoard */ = {
isa = PBXNativeTarget;
buildConfigurationList = BB0000000000000000000010 /* Build configuration list for PBXNativeTarget "FuelBoard" */;
buildPhases = (
BB0000000000000000000011 /* Sources */,
BB000000000000000000000B /* Frameworks */,
BB0000000000000000000012 /* Resources */,
ADCE5B5B3033A32C008C131C /* Embed Watch Content */,
BB0000000000000000000006 /* Embed App Extensions */,
);
buildRules = (
);
dependencies = (
ADCE5B703033A32F008C131C /* PBXTargetDependency */,
BB0000000000000000000013 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
BB0000000000000000000008 /* FuelBoard */,
BB000000000000000000000A /* Shared */,
);
name = FuelBoard;
packageProductDependencies = (
);
productName = FuelBoard;
productReference = BB0000000000000000000007 /* FuelBoard.app */;
productType = "com.apple.product-type.application";
};
FBA000000000000000000300 /* FuelBoardSharedTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = FBA000000000000000000400 /* Build configuration list for PBXNativeTarget "FuelBoardSharedTests" */;
buildPhases = (
FBA000000000000000000301 /* Sources */,
FBA000000000000000000302 /* Frameworks */,
FBA000000000000000000303 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = FuelBoardSharedTests;
productName = FuelBoardSharedTests;
productReference = AD64FC4C30739A4B008F27A9 /* FuelBoardSharedTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@@ -155,15 +363,23 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = 1; BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1600; LastSwiftUpdateCheck = 2630;
LastUpgradeCheck = 1600; LastUpgradeCheck = 1600;
TargetAttributes = { TargetAttributes = {
BB000000000000000000000F = { ADCE5B4E3033A329008C131C = {
CreatedOnToolsVersion = 16.0; CreatedOnToolsVersion = 26.3;
}; };
BB0000000000000000000005 = { BB0000000000000000000005 = {
CreatedOnToolsVersion = 16.0; CreatedOnToolsVersion = 16.0;
}; };
BB000000000000000000000F = {
CreatedOnToolsVersion = 16.0;
SystemCapabilities = {
com.apple.InAppPurchase = {
enabled = 1;
};
};
};
}; };
}; };
buildConfigurationList = BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */; buildConfigurationList = BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */;
@@ -182,11 +398,28 @@
targets = ( targets = (
BB000000000000000000000F /* FuelBoard */, BB000000000000000000000F /* FuelBoard */,
BB0000000000000000000005 /* FuelBoardWidgets */, BB0000000000000000000005 /* FuelBoardWidgets */,
ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */,
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */,
FBA000000000000000000300 /* FuelBoardSharedTests */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */
ADCE5B4D3033A329008C131C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
ADCE5B6E3033A32E008C131C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
BB0000000000000000000012 /* Resources */ = { BB0000000000000000000012 /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -201,9 +434,23 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
FBA000000000000000000303 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
ADCE5B4B3033A329008C131C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
BB0000000000000000000011 /* Sources */ = { BB0000000000000000000011 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -218,9 +465,36 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
FBA000000000000000000301 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
FBA000000000000000000001 /* DataRefreshMode.swift in Sources */,
FBA000000000000000000002 /* FuelHistory.swift in Sources */,
FBA000000000000000000003 /* FuelPriceProvider.swift in Sources */,
FBA000000000000000000004 /* FuelStore.swift in Sources */,
FBA000000000000000000005 /* MirrorFuelProvider.swift in Sources */,
FBA000000000000000000006 /* SiriCheapestLookup.swift in Sources */,
FBA000000000000000000007 /* WidgetLocationFetcher.swift in Sources */,
FBA000000000000000000008 /* FuelBoardTests.swift in Sources */,
FBA000000000000000000009 /* FuelHistoryTests.swift in Sources */,
FBA00000000000000000000A /* LiveChainTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
ADCE5B683033A32D008C131C /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */;
targetProxy = ADCE5B643033A32D008C131C /* PBXContainerItemProxy */;
};
ADCE5B703033A32F008C131C /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */;
targetProxy = ADCE5B6F3033A32F008C131C /* PBXContainerItemProxy */;
};
BB0000000000000000000013 /* PBXTargetDependency */ = { BB0000000000000000000013 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = BB0000000000000000000005 /* FuelBoardWidgets */; target = BB0000000000000000000005 /* FuelBoardWidgets */;
@@ -229,6 +503,266 @@
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
ADCE5B573033A32B008C131C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/WatchExtension-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.2;
};
name = Debug;
};
ADCE5B583033A32B008C131C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/WatchExtension-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.2;
};
name = Release;
};
ADCE5B6B3033A32D008C131C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/WatchWrapper-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.2;
};
name = Debug;
};
ADCE5B6C3033A32D008C131C /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = "Config/WatchWrapper-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 26.2;
};
name = Release;
};
BB0000000000000000000018 /* Debug */ = { BB0000000000000000000018 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -290,17 +824,20 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/App-Info.plist; INFOPLIST_FILE = "Config/App-Info.plist";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard; PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = 1;
@@ -312,17 +849,20 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements; CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/App-Info.plist; INFOPLIST_FILE = "Config/App-Info.plist";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard; PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = 1;
@@ -335,15 +875,16 @@
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 = 1; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/Widget-Info.plist; INFOPLIST_FILE = "Config/Widget-Info.plist";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets; PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -359,15 +900,16 @@
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 = 1; CURRENT_PROJECT_VERSION = 30;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/Widget-Info.plist; INFOPLIST_FILE = "Config/Widget-Info.plist";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets; PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
@@ -377,14 +919,69 @@
}; };
name = Release; name = Release;
}; };
FBA000000000000000000401 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FuelBoardSharedTests;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.sharedtests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
FBA000000000000000000402 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = FuelBoardSharedTests;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.sharedtests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */ = { ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App Extension" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
BB0000000000000000000018 /* Debug */, ADCE5B573033A32B008C131C /* Debug */,
BB0000000000000000000019 /* Release */, ADCE5B583033A32B008C131C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
ADCE5B6A3033A32D008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
ADCE5B6B3033A32D008C131C /* Debug */,
ADCE5B6C3033A32D008C131C /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
@@ -407,6 +1004,24 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BB0000000000000000000018 /* Debug */,
BB0000000000000000000019 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
FBA000000000000000000400 /* Build configuration list for PBXNativeTarget "FuelBoardSharedTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
FBA000000000000000000401 /* Debug */,
FBA000000000000000000402 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = BB0000000000000000000004 /* Project object */; rootObject = BB0000000000000000000004 /* Project object */;
@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2600"
version = "1.8">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BB000000000000000000000F"
BuildableName = "FuelBoard.app"
BlueprintName = "FuelBoard"
ReferencedContainer = "container:FuelBoard.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FBA000000000000000000300"
BuildableName = "FuelBoardSharedTests.xctest"
BlueprintName = "FuelBoardSharedTests"
ReferencedContainer = "container:FuelBoard.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<StoreKitConfigurationFileReference
identifier = "../../../FuelBoardTips.storekit">
</StoreKitConfigurationFileReference>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
language = "en"
region = "GB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersion = "1.0"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BB000000000000000000000F"
BuildableName = "FuelBoard.app"
BlueprintName = "FuelBoard"
ReferencedContainer = "container:FuelBoard.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersion = "1.0"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BB000000000000000000000F"
BuildableName = "FuelBoard.app"
BlueprintName = "FuelBoard"
ReferencedContainer = "container:FuelBoard.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+153 -59
View File
@@ -6,12 +6,14 @@ 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 favouriteDropEnabled: Bool
@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).
@Binding var followsSearch: Bool @Binding var followsSearch: Bool
/// Whether the "cheapest nearby" Live Activity is shown on the Lock Screen /// Whether the "cheapest nearby" Live Activity is shown on the Lock Screen
@@ -30,12 +32,13 @@ struct AlertsView: View {
let distanceUnit: DistanceUnit let distanceUnit: DistanceUnit
let monitoredCount: Int let monitoredCount: Int
let lastAlert: String? let lastAlert: String?
let onCheckFavouriteDropNow: () -> Void
/// The Stations-tab search distance shown in the user's unit, e.g. /// The Stations-tab search distance shown in the user's unit, e.g.
/// "5 miles" or "8 km" — for the "Follow search" rows. /// "5 miles" or "8 km" — for the "Follow search" rows.
private var searchInUnitLabel: String { private var searchInUnitLabel: String {
let shown = distanceUnit.displayMiles(stationLimit) // stationLimit is always miles let shown = distanceUnit.displayMiles(stationLimit) // stationLimit is always miles
return "\(shown) \(distanceUnit.label(for: Double(shown)))" return String(format: String(localized: "%lld %@", comment: "Distance label"), shown, distanceUnit.label(for: Double(shown)))
} }
/// A miles value (5/10/15) shown truthfully in the user's unit, e.g. /// A miles value (5/10/15) shown truthfully in the user's unit, e.g.
@@ -43,34 +46,32 @@ struct AlertsView: View {
/// what the option really means. /// what the option really means.
private func distanceLabel(forMiles miles: Int) -> String { private func distanceLabel(forMiles miles: Int) -> String {
let shown = distanceUnit.displayMiles(miles) let shown = distanceUnit.displayMiles(miles)
return "\(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 "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing)." let capped = stationLimit > followSearchCapMiles
case .fixed(let value): let label = distanceLabel(forMiles: capped ? followSearchCapMiles : stationLimit)
let optionLabel = "\(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 = "Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips."
case 2:
body = "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town."
case 3:
body = "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns."
case 5:
body = "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."
default:
body = "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires."
} }
return "\(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)
} }
} }
@@ -127,63 +128,156 @@ 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("Cheapest-station alerts", isOn: $enabled) Toggle(String(localized: "Cheapest favourite", comment: "Favourite price-drop alert toggle"), isOn: $favouriteDropEnabled)
Picker("Fuel", selection: $fuel) { Group {
ForEach(FuelType.allCases) { fuel in optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
Text(fuel.displayName).tag(fuel) value: favouriteDropFuel.displayName,
isEnabled: favouriteDropEnabled) {
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $favouriteDropFuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
}
}
} }
} Button(String(localized: "Check now", comment: "Manual favourite price-drop check button"), action: onCheckFavouriteDropNow)
Picker("Radius", selection: alertChoice) { 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"))
Text("Follow search (\(searchInUnitLabel))").tag(AlertRadiusChoice.followSearch) .font(.footnote)
ForEach(FuelStore.alertRadiusOptions, id: \.self) { value in
Text("\(value) \(distanceUnit.label(for: Double(value)))").tag(AlertRadiusChoice.fixed(value))
}
}
Text(alertRadiusGuidance)
.font(.caption)
.foregroundStyle(.secondary)
if enabled && monitoredCount == 0 {
Text("No stations monitored yet — open the Stations tab to load prices first.")
.font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.fuelBoardDisabledState(isEnabled: favouriteDropEnabled)
} header: { } header: {
Text("Cheapest-station alerts") Text(String(localized: "Favourite price alerts", comment: "Favourite price alerts section title"))
} footer: { }
Text("When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling \(fuel.displayName.lowercased()) 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.")
Section {
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
Group {
Text(String(localized: "Get notified when you're near one of the cheapest stations for your chosen fuel.", comment: "Cheapest station proximity alert description"))
.font(.footnote)
.foregroundStyle(.secondary)
optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
value: fuel.displayName,
isEnabled: enabled) {
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $fuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
}
}
}
optionRow(String(localized: "Radius", comment: "Alert radius picker title"),
value: alertRadiusValueLabel,
isEnabled: enabled) {
Picker(String(localized: "Radius", comment: "Alert radius picker title"), selection: alertChoice) {
Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(AlertRadiusChoice.followSearch)
ForEach(FuelStore.alertRadiusOptions, id: \.self) { value in
Text(String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))).tag(AlertRadiusChoice.fixed(value))
}
}
}
Text(alertRadiusDescription)
.font(.footnote)
.foregroundStyle(.secondary)
if enabled && monitoredCount == 0 {
Text(String(localized: "No stations monitored yet — open the Stations tab to load prices first.", comment: "No monitored stations hint"))
.font(.footnote)
.foregroundStyle(.secondary)
}
Text(String(localized: "Background alerts depend on when your iPhone allows them; each station alerts at most once an hour.", comment: "Proximity alerts background limitation note"))
.font(.footnote)
.foregroundStyle(.secondary)
}
.fuelBoardDisabledState(isEnabled: enabled)
} header: {
Text(String(localized: "Proximity alerts", comment: "Proximity alerts section title"))
} }
if enabled, let lastAlert { if enabled, let lastAlert {
Section("Last alert") { Section(String(localized: "Last alert", comment: "Last alert section title")) {
Label(lastAlert, systemImage: "bell.badge.fill") Label(lastAlert, systemImage: "bell.badge.fill")
.font(.footnote) .font(.footnote)
} }
} }
Section { Section {
Toggle("Live Activity", isOn: $liveActivityEnabled) Toggle(String(localized: "Cheapest station", comment: "Live Activity toggle"), isOn: $liveActivityEnabled)
Picker("Fuel", selection: $liveActivityFuel) { Group {
ForEach(FuelType.allCases) { fuel in optionRow(String(localized: "Fuel", comment: "Fuel picker title"),
Text(fuel.displayName).tag(fuel) value: liveActivityFuel.displayName,
isEnabled: liveActivityEnabled) {
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $liveActivityFuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
}
} }
} }
Picker("Distance", selection: laChoice) { optionRow(String(localized: "Distance", comment: "Distance picker title"),
Text("Follow search (\(searchInUnitLabel))").tag(LADistanceChoice.followSearch) value: laDistanceValueLabel,
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in isEnabled: liveActivityEnabled) {
Text(distanceLabel(forMiles: miles)).tag(LADistanceChoice.fixed(miles)) Picker(String(localized: "Distance", comment: "Distance picker title"), selection: laChoice) {
Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(LADistanceChoice.followSearch)
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
Text(distanceLabel(forMiles: miles)).tag(LADistanceChoice.fixed(miles))
}
} }
} }
if liveActivityFollowsSearch {
Text(String(localized: "Uses the same distance as your station search.", comment: "Live Activity distance description, follow search"))
.font(.footnote)
.foregroundStyle(.secondary)
}
Text(String(localized: "See the cheapest nearby station on your Lock Screen and Dynamic Island, with updates as you travel.", comment: "Live Activity section description"))
.font(.footnote)
.foregroundStyle(.secondary)
}
.fuelBoardDisabledState(isEnabled: liveActivityEnabled)
} header: { } header: {
Text("Live Activity") Text(String(localized: "Live Activity", comment: "Live Activity section title"))
} footer: {
Text("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.")
} }
} }
.navigationTitle("Alerts") .navigationTitle(String(localized: "Alerts", comment: "Alerts tab title"))
} }
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 747 KiB

@@ -1,38 +1,170 @@
{ {
"images" : [ "images": [
{ {
"filename" : "AppIcon.png", "filename": "AppIcon.png",
"idiom" : "universal", "idiom": "universal",
"platform" : "ios", "platform": "ios",
"size" : "1024x1024" "size": "1024x1024"
}, },
{ {
"appearances" : [ "appearances": [
{ {
"appearance" : "luminosity", "appearance": "luminosity",
"value" : "dark" "value": "dark"
} }
], ],
"filename" : "AppIcon-dark.png", "filename": "AppIcon-dark.png",
"idiom" : "universal", "idiom": "universal",
"platform" : "ios", "platform": "ios",
"size" : "1024x1024" "size": "1024x1024"
}, },
{ {
"appearances" : [ "appearances": [
{ {
"appearance" : "luminosity", "appearance": "luminosity",
"value" : "tinted" "value": "tinted"
} }
], ],
"filename" : "AppIcon-tinted.png", "filename": "AppIcon-tinted.png",
"idiom" : "universal", "idiom": "universal",
"platform" : "ios", "platform": "ios",
"size" : "1024x1024" "size": "1024x1024"
},
{
"filename": "Watch-NotificationCenter-48.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "24x24",
"subtype": "38mm"
},
{
"filename": "Watch-NotificationCenter-55.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "27.5x27.5",
"subtype": "42mm"
},
{
"filename": "Watch-NotificationCenter-66.png",
"idiom": "watch",
"role": "notificationCenter",
"scale": "2x",
"size": "33x33",
"subtype": "45mm"
},
{
"filename": "Watch-CompanionSettings-58.png",
"idiom": "watch",
"role": "companionSettings",
"scale": "2x",
"size": "29x29"
},
{
"filename": "Watch-CompanionSettings-87.png",
"idiom": "watch",
"role": "companionSettings",
"scale": "3x",
"size": "29x29"
},
{
"filename": "Watch-AppLauncher-80.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "40x40",
"subtype": "38mm"
},
{
"filename": "Watch-AppLauncher-88.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "44x44",
"subtype": "40mm"
},
{
"filename": "Watch-AppLauncher-92.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "46x46",
"subtype": "41mm"
},
{
"filename": "Watch-AppLauncher-100.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "50x50",
"subtype": "44mm"
},
{
"filename": "Watch-AppLauncher-102.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "51x51",
"subtype": "45mm"
},
{
"filename": "Watch-AppLauncher-108.png",
"idiom": "watch",
"role": "appLauncher",
"scale": "2x",
"size": "54x54",
"subtype": "49mm"
},
{
"filename": "Watch-QuickLook-172.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "86x86",
"subtype": "38mm"
},
{
"filename": "Watch-QuickLook-196.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "98x98",
"subtype": "42mm"
},
{
"filename": "Watch-QuickLook-216.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "108x108",
"subtype": "44mm"
},
{
"filename": "Watch-QuickLook-234.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "117x117",
"subtype": "45mm"
},
{
"filename": "Watch-QuickLook-258.png",
"idiom": "watch",
"role": "quickLook",
"scale": "2x",
"size": "129x129",
"subtype": "49mm"
},
{
"filename": "Watch-Marketing-1024.png",
"idiom": "watch-marketing",
"scale": "1x",
"size": "1024x1024"
} }
], ],
"info" : { "info": {
"author" : "xcode", "author": "xcode",
"version" : 1 "version": 1
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 747 KiB

@@ -4,6 +4,14 @@
"filename" : "AppIconArtwork.png", "filename" : "AppIconArtwork.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info" : { "info" : {
Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "AppIconPreview.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "AppIconPreviewDark.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "PreviewWallpaper.jpg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

@@ -1,18 +1,22 @@
{ {
"images": [ "images" : [
{ {
"filename": "brand_applegreen_2x.png", "idiom" : "universal",
"idiom": "universal", "scale" : "1x"
"scale": "2x"
}, },
{ {
"filename": "brand_applegreen_3x.png", "filename" : "brand_applegreen_2x.png",
"idiom": "universal", "idiom" : "universal",
"scale": "3x" "scale" : "2x"
},
{
"filename" : "brand_applegreen_3x.png",
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info": { "info" : {
"author": "xcode", "author" : "xcode",
"version": 1 "version" : 1
} }
} }
+15 -11
View File
@@ -1,18 +1,22 @@
{ {
"images": [ "images" : [
{ {
"filename": "brand_asda_2x.png", "idiom" : "universal",
"idiom": "universal", "scale" : "1x"
"scale": "2x"
}, },
{ {
"filename": "brand_asda_3x.png", "filename" : "brand_asda_2x.png",
"idiom": "universal", "idiom" : "universal",
"scale": "3x" "scale" : "2x"
},
{
"filename" : "brand_asda_3x.png",
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info": { "info" : {
"author": "xcode", "author" : "xcode",
"version": 1 "version" : 1
} }
} }
+15 -11
View File
@@ -1,18 +1,22 @@
{ {
"images": [ "images" : [
{ {
"filename": "brand_bp_2x.png", "idiom" : "universal",
"idiom": "universal", "scale" : "1x"
"scale": "2x"
}, },
{ {
"filename": "brand_bp_3x.png", "filename" : "brand_bp_2x.png",
"idiom": "universal", "idiom" : "universal",
"scale": "3x" "scale" : "2x"
},
{
"filename" : "brand_bp_3x.png",
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info": { "info" : {
"author": "xcode", "author" : "xcode",
"version": 1 "version" : 1
} }
} }
@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "brand_circle_k_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_circle_k_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "brand_essar_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_essar_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+15 -11
View File
@@ -1,18 +1,22 @@
{ {
"images": [ "images" : [
{ {
"filename": "brand_esso_2x.png", "idiom" : "universal",
"idiom": "universal", "scale" : "1x"
"scale": "2x"
}, },
{ {
"filename": "brand_esso_3x.png", "filename" : "brand_esso_2x.png",
"idiom": "universal", "idiom" : "universal",
"scale": "3x" "scale" : "2x"
},
{
"filename" : "brand_esso_3x.png",
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info": { "info" : {
"author": "xcode", "author" : "xcode",
"version": 1 "version" : 1
} }
} }
@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "brand_gleaner_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_gleaner_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

+15 -11
View File
@@ -1,18 +1,22 @@
{ {
"images": [ "images" : [
{ {
"filename": "brand_gulf_2x.png", "idiom" : "universal",
"idiom": "universal", "scale" : "1x"
"scale": "2x"
}, },
{ {
"filename": "brand_gulf_3x.png", "filename" : "brand_gulf_2x.png",
"idiom": "universal", "idiom" : "universal",
"scale": "3x" "scale" : "2x"
},
{
"filename" : "brand_gulf_3x.png",
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info": { "info" : {
"author": "xcode", "author" : "xcode",
"version": 1 "version" : 1
} }
} }
@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "brand_highland_fuels_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_highland_fuels_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

+15 -11
View File
@@ -1,18 +1,22 @@
{ {
"images": [ "images" : [
{ {
"filename": "brand_jet_2x.png", "idiom" : "universal",
"idiom": "universal", "scale" : "1x"
"scale": "2x"
}, },
{ {
"filename": "brand_jet_3x.png", "filename" : "brand_jet_2x.png",
"idiom": "universal", "idiom" : "universal",
"scale": "3x" "scale" : "2x"
},
{
"filename" : "brand_jet_3x.png",
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info": { "info" : {
"author": "xcode", "author" : "xcode",
"version": 1 "version" : 1
} }
} }
@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "brand_maxol_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_maxol_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

@@ -1,18 +1,22 @@
{ {
"images": [ "images" : [
{ {
"filename": "brand_morrisons_2x.png", "idiom" : "universal",
"idiom": "universal", "scale" : "1x"
"scale": "2x"
}, },
{ {
"filename": "brand_morrisons_3x.png", "filename" : "brand_morrisons_2x.png",
"idiom": "universal", "idiom" : "universal",
"scale": "3x" "scale" : "2x"
},
{
"filename" : "brand_morrisons_3x.png",
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info": { "info" : {
"author": "xcode", "author" : "xcode",
"version": 1 "version" : 1
} }
} }
@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "brand_murco_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_murco_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -1,18 +1,22 @@
{ {
"images": [ "images" : [
{ {
"filename": "brand_sainsburys_2x.png", "idiom" : "universal",
"idiom": "universal", "scale" : "1x"
"scale": "2x"
}, },
{ {
"filename": "brand_sainsburys_3x.png", "filename" : "brand_sainsburys_2x.png",
"idiom": "universal", "idiom" : "universal",
"scale": "3x" "scale" : "2x"
},
{
"filename" : "brand_sainsburys_3x.png",
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info": { "info" : {
"author": "xcode", "author" : "xcode",
"version": 1 "version" : 1
} }
} }
+15 -11
View File
@@ -1,18 +1,22 @@
{ {
"images": [ "images" : [
{ {
"filename": "brand_shell_2x.png", "idiom" : "universal",
"idiom": "universal", "scale" : "1x"
"scale": "2x"
}, },
{ {
"filename": "brand_shell_3x.png", "filename" : "brand_shell_2x.png",
"idiom": "universal", "idiom" : "universal",
"scale": "3x" "scale" : "2x"
},
{
"filename" : "brand_shell_3x.png",
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info": { "info" : {
"author": "xcode", "author" : "xcode",
"version": 1 "version" : 1
} }
} }
@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "brand_spar_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_spar_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

+15 -11
View File
@@ -1,18 +1,22 @@
{ {
"images": [ "images" : [
{ {
"filename": "brand_tesco_2x.png", "idiom" : "universal",
"idiom": "universal", "scale" : "1x"
"scale": "2x"
}, },
{ {
"filename": "brand_tesco_3x.png", "filename" : "brand_tesco_2x.png",
"idiom": "universal", "idiom" : "universal",
"scale": "3x" "scale" : "2x"
},
{
"filename" : "brand_tesco_3x.png",
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info": { "info" : {
"author": "xcode", "author" : "xcode",
"version": 1 "version" : 1
} }
} }
+15 -11
View File
@@ -1,18 +1,22 @@
{ {
"images": [ "images" : [
{ {
"filename": "brand_texaco_2x.png", "idiom" : "universal",
"idiom": "universal", "scale" : "1x"
"scale": "2x"
}, },
{ {
"filename": "brand_texaco_3x.png", "filename" : "brand_texaco_2x.png",
"idiom": "universal", "idiom" : "universal",
"scale": "3x" "scale" : "2x"
},
{
"filename" : "brand_texaco_3x.png",
"idiom" : "universal",
"scale" : "3x"
} }
], ],
"info": { "info" : {
"author": "xcode", "author" : "xcode",
"version": 1 "version" : 1
} }
} }
@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "brand_the_co_operative_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_the_co_operative_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "brand_valero_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_valero_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

@@ -0,0 +1,22 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "brand_welcome_break_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_welcome_break_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

+313
View File
@@ -0,0 +1,313 @@
import SwiftUI
/// The Best Value page body: what the feature does, whether to switch it on, and
/// — only when it is on — the two figures the ranking needs, entered with the
/// same sliders Settings uses (one control, no text entry, so a first-run user
/// can't stall on a keyboard).
///
/// Shared deliberately. First-run onboarding shows it as one of its pages, and
/// existing users who updated into the feature get the same page as a
/// once-per-announcement-version announcement (`BestValueIntroView`). Two copies
/// would drift the first time either was edited, so the copy, the controls and
/// the "declining is a decision" rule all live here.
///
/// Settings are written to the store as the controls move — the same
/// immediate-persist rule as Settings — so a host dismissed mid-page cannot lose
/// a choice the user has already made.
struct BestValueIntroContent: View {
@Binding var settings: FuelSavingsSettings
/// What the host's primary button says. ONE label for both answers: the
/// toggle already shows whether Best Value is being switched on, so a
/// separate "Not now" for the declined case was second-guessing a choice the
/// user could see on screen.
static var primaryTitle: String {
String(localized: "Continue", comment: "Best Value continue button")
}
var body: some View {
VStack(spacing: 0) {
Spacer(minLength: 8)
AnimatedOnboardingIcon(tint: .green) {
Image(systemName: "sterlingsign.circle.fill")
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(.green)
}
.padding(.bottom, 20)
Text(String(localized: "Best Value", comment: "Best Value title"))
.font(.largeTitle.bold())
Text(String(localized: "The cheapest pump isn't always the cheapest fill-up — driving further burns fuel. Best Value sorts stations by what you'd really save once the trip is counted.", comment: "Best Value explanation"))
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 28)
.padding(.top, 10)
card
.padding(.horizontal, 24)
.padding(.top, 16)
Spacer(minLength: 8)
}
}
private var card: some View {
VStack(spacing: 14) {
Toggle(isOn: enabledBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(String(localized: "Turn on Best Value", comment: "Best Value toggle"))
.font(.headline)
Text(String(localized: "Sort by what a station saves you, travel included.", comment: "Best Value toggle detail"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
if settings.enabled {
slider(
title: String(localized: "Average fuel economy", comment: "Best Value MPG slider label"),
readOut: mpgText,
value: mpgBinding,
range: FuelSavingsCalculator.mpgSliderRange,
step: FuelSavingsCalculator.mpgSliderStep
)
Picker("Fill-up unit", selection: unitBinding) {
ForEach(FuelSavingsFillUnit.allCases) { unit in
Text(unit.displayName).tag(unit)
}
}
.pickerStyle(.segmented)
slider(
title: String(localized: "Fill-up amount", comment: "Best Value fill-up slider label"),
readOut: fillUpText,
value: amountBinding,
range: FuelSavingsCalculator.fillAmountSliderRange(for: settings.preferredAmount.unit),
step: FuelSavingsCalculator.fillAmountSliderStep(for: settings.preferredAmount.unit)
)
Text(String(localized: "Best Value uses these, and can be changed any time in Settings.", comment: "Best Value configured note"))
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
} else {
Text(String(localized: "That's fine — you can turn Best Value on whenever you like in Settings, under Fuel Savings.", comment: "Best Value declined note"))
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(.secondarySystemBackground))
)
}
/// The sliders are the only controls for these two figures, exactly as in
/// Settings: a labelled read-out above a slider, no text field.
private func slider(title: String,
readOut: String,
value: Binding<Double>,
range: ClosedRange<Double>,
step: Double) -> some View {
VStack(spacing: 2) {
HStack {
Text(title)
.font(.subheadline)
Spacer()
Text(readOut)
.font(.subheadline)
.monospacedDigit()
.foregroundStyle(.secondary)
}
Slider(value: value, in: range, step: step)
.accessibilityLabel(title)
.accessibilityValue(readOut)
}
}
// MARK: Bindings (persist immediately, exactly like Settings)
private var enabledBinding: Binding<Bool> {
Binding(
get: { settings.enabled },
set: { settings.enabled = $0; persist() }
)
}
private var mpgBinding: Binding<Double> {
Binding(
get: { settings.mpg },
set: {
settings.mpg = min(max($0, 0), FuelSavingsCalculator.maximumMPG)
persist()
}
)
}
private var amountBinding: Binding<Double> {
Binding(
get: { settings.preferredAmount.value },
set: {
settings.preferredAmount.value = min(
max($0, 0),
FuelSavingsCalculator.maximumFillAmount(for: settings.preferredAmount.unit)
)
persist()
}
)
}
private var unitBinding: Binding<FuelSavingsFillUnit> {
Binding(
get: { settings.preferredAmount.unit },
set: { newUnit in
settings.preferredAmount.unit = newUnit
// Same guard as Settings: a figure valid in one unit may be
// valid in neither after the switch, so fall back to a sensible
// default for the new unit rather than storing junk.
if !settings.preferredAmount.isValid {
settings.preferredAmount.value = newUnit == .pounds ? 40 : 30
}
persist()
}
)
}
private func persist() {
FuelStore.saveFuelSavingsSettings(settings)
}
/// "45 MPG" / "45.5 MPG" — the slider is the only control, so this read-out
/// is the one place the figure is stated.
private var mpgText: String {
let mpg = settings.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.
private var fillUpText: String {
switch settings.preferredAmount.unit {
case .pounds: return String(format: "£%.0f", settings.preferredAmount.value)
case .litres: return String(format: "%.0f L", settings.preferredAmount.value)
}
}
}
/// The announcement existing users see once per announcement version: the Best
/// Value page on its own. Deliberately NOT the onboarding pager — no page dots
/// and no permission pages, because those users answered all of that long ago
/// and the full flow is mandatory with no skip.
struct BestValueIntroView: View {
var onFinish: () -> Void
@State private var settings = FuelStore.loadFuelSavingsSettings()
var body: some View {
VStack(spacing: 0) {
BestValueIntroContent(settings: $settings)
OnboardingPrimaryButton(title: BestValueIntroContent.primaryTitle,
action: finish)
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
.background(
LinearGradient(
colors: [Color(.systemBackground), Color.accentColor.opacity(0.06)],
startPoint: .top, endPoint: .bottom
)
.ignoresSafeArea()
)
}
private func finish() {
// Marked on the way OUT, so a crash or force-quit mid-page re-shows the
// announcement instead of silently swallowing it.
FuelStore.markBestValueIntroSeen()
onFinish()
}
}
/// Presents the Best Value announcement over the app, exactly once per
/// announcement version. It wraps whatever it is given, so the whole feature —
/// state, gate and dismissal — lives in this file and ContentView only has to
/// install it.
///
/// The gate, in order:
/// - suppressed under `-skipOnboarding` (DEBUG), so screenshot runs can open a
/// tab without a cover interrupting the capture;
/// - forced by `-showBestValueIntro` (DEBUG);
/// - never while first-run onboarding is due or on screen — that flow contains
/// the same page, and `OnboardingView.finish` marks this seen;
/// - never when the stored announcement version is current;
/// - never for someone who has already configured Best Value themselves.
struct BestValueAnnouncementHost<Content: View>: View {
/// True while the first-run onboarding cover is up: the announcement must
/// not fight it for the screen.
var isSuppressed: Bool = false
var onDismiss: () -> Void = {}
@ViewBuilder var content: Content
@State private var presenting = false
var body: some View {
content
.fullScreenCover(isPresented: $presenting) {
BestValueIntroView {
presenting = false
}
}
// `task`, not `onAppear`: runs once when the app's content appears,
// after ContentView has made its onboarding decision.
.task {
guard !presenting, shouldAnnounce else { return }
presenting = true
}
.onChange(of: presenting) { _, showing in
guard !showing else { return }
// The page writes straight to the store — it is its own cover
// with its own copy of the settings — so the app's in-memory
// copy has to be pulled back in, or an announcement choice
// wouldn't apply until the next launch.
onDismiss()
}
}
private var shouldAnnounce: Bool {
#if DEBUG
let args = ProcessInfo.processInfo.arguments
if args.contains("-skipOnboarding") { return false }
if args.contains("-showBestValueIntro") { return true }
#endif
// The rule itself lives in the store so it can be unit-tested — see
// `FuelStore.shouldAnnounceBestValue`.
return FuelStore.shouldAnnounceBestValue(
settings: FuelStore.loadFuelSavingsSettings(),
isSuppressed: isSuppressed
)
}
}
/// The prominent button both Best Value hosts use, so they can't drift apart.
struct OnboardingPrimaryButton: View {
var title: String
var action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
}
}
+10
View File
@@ -15,4 +15,14 @@ enum BundledDumpProvider {
guard let asset = NSDataAsset(name: "FuelBoardDump") else { return nil } guard let asset = NSDataAsset(name: "FuelBoardDump") else { return nil }
return try? FuelPriceProvider.decodeStations(from: asset.data) return try? FuelPriceProvider.decodeStations(from: asset.data)
} }
/// The GOV.UK `data_updated` stamp from the bundled envelope (ISO 8601).
/// Labels the offline-data banner honestly ("Offline data from 15 Aug")
/// instead of presenting the build's snapshot as live.
static var dataUpdatedStamp: String? {
guard let asset = NSDataAsset(name: "FuelBoardDump"),
let obj = try? JSONSerialization.jsonObject(with: asset.data)
as? [String: Any] else { return nil }
return obj["data_updated"] as? String
}
} }
+1740 -194
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More