Compare commits

...
Author SHA1 Message Date
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 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 e004f25a31 feat: add real watch companion and dual IPA build workflow 2026-08-17 22:41:12 +01:00
160 changed files with 10926 additions and 1090 deletions
+3
View File
@@ -1,6 +1,9 @@
build/
Builds/
build-*/
build-release/
build-sim/
build-watch/
.DS_Store
*.xcuserstate
xcuserdata/
+130 -28
View File
@@ -1,6 +1,6 @@
# FuelBoard App Store Submission Plan
Updated: 2026-08-16 · Target: safe App Store submission.
Updated: 2026-09-26 · Target: safe App Store submission.
Companion to SECURITY.md (code security) — this file is the *review-gate*
checklist: what must happen, who owns it, and the timeline.
@@ -13,10 +13,108 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
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.
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.
## StoreKit sandbox: verified working configuration and caveats
### Verified configuration
The FuelBoard consumable tip products are:
- `com.apt.fuelboard.tip099`
- `com.apt.fuelboard.tip299`
- `com.apt.fuelboard.tip499`
The app requests these exact **Product IDs** with StoreKit 2
`Product.products(for:)`; the UI names are display labels and are not used for
lookup. The app bundle ID, App Store Connect app, explicit App ID, development
team, and Sandbox Apple Account must belong to the same developer account.
Two test environments must remain distinct:
- **Local StoreKit testing:** select `/Users/apt/workspace/fuelboard/FuelBoardTips.storekit`
in Xcode, under Product > Scheme > Edit Scheme > Run > Options. This bypasses
App Store Connect and confirms the app's StoreKit loading and purchase code.
- **Apple sandbox testing:** select **None** for StoreKit Configuration, then
use Product > Run on a physical device with the development-signed build and
a Sandbox Apple Account signed in under Settings > Developer. This exercises
the real App Store Connect catalog.
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
@@ -25,31 +123,32 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
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 2026-08-16** (app + widget
manifests added; UserDefaults `1C8F.1`, Location + Diagnostics declared).
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. A one-page GitHub Pages privacy policy is fine.
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 manifests added (2026-08-16):
UserDefaults reason `1C8F.1`; app also declares Location (linked, not
tracking) + Diagnostics (not linked, not tracking — the dev beacon)
- [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 bump** → `1.0.1` (build 2), app + widget plists
- [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)
- [ ] ~~Gate dev surface behind #if DEBUG~~ **DECLINED — kept in Release on
purpose**: the user's home-testing installs the Release IPA and toggles
`fuelboard.relayFallback` via Settings → Debug (revealed only by tapping
About → Version ×5 — invisible to reviewers; no credentials involved).
Gating it out of Release would break that workflow for zero review gain.
- [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,
@@ -75,8 +174,7 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
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; diagnostics: yes — the widget-diag beacon, not
linked, not used for tracking; no ATT prompt needed)
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
@@ -90,19 +188,23 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
> Prices are downloaded from the internet up to twice a day; the app filters
> 5/10/15-mile radii on-device and works offline from a bundled snapshot.
> Location is used to (a) rank stations by distance and (b) power the
> optional Alerts feature: when the user enables alerts, the app geofences
> the cheapest station in their radius and sends a notification when they
> approach it. 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.
> optional Alerts feature: when the user enables alerts, the app registers
> CoreLocation regions around the cheapest station in their radius and sends
> 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.
## Review risks (know before submitting)
- **Always-location (5.1.1)** — the #1 question. The feature is visible
(Alerts tab) and opt-in; the review notes above say so explicitly.
- **Always-location (5.1.1 / 2.5.4)** — the feature is visible (Alerts tab)
and opt-in; the app does not declare persistent background location mode,
and the review notes describe region monitoring/significant-change rather
than continuous tracking.
- **Reviewer outside the UK** — empty radius; mitigated by the empty-state
task above + review notes.
- **"Cheapest" claims (2.3.1)** — mitigated by the OGL/footnote task.
@@ -110,8 +212,8 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
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 data, no credentials; bundled dump
covers any regional GitHub unreachability.
- **GitHub raw as data source** — public HTTPS data, no credentials or Local
Network permission; bundled dump covers any regional GitHub unreachability.
## Timeline (best case)
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

+7 -10
View File
@@ -4,7 +4,7 @@ FuelBoard — UK-wide petrol-station price widget + app.
GitHub mirror (`aptonline/fuelboard-data`, 2×/day 10:00+16:00) holds the
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 is dev-only (hidden `fuelboard.relayFallback` flag).
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.
Status: TODO / IN PROGRESS / DONE / BLOCKED.
@@ -211,15 +211,12 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
deploy key, 180-day retention) + history read side (Trends, P1) + **live chain
(`feature/p0-live-chain`)**: `MirrorFuelProvider` fetches CURRENT prices from
raw.githubusercontent (latest.json → history/<day>.json, app-group day-cache) →
**GitHub FIRST everywhere** (alert checks + widget too — the relay is
unreachable from any consumer phone and attempting it would fire the Local
Network prompt); LAN relay joins ONLY behind the hidden dev flag
`fuelboard.relayFallback` (Settings → Debug, off by default); bundled REAL
dump (`FuelBoardDump` dataset, refreshed by `scripts/refresh_bundled_dump.sh`)
is the app's no-network last resort. `FuelBeacon` fire-and-forget X-Client app
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.
**GitHub mirror only everywhere** (alert checks + widget too); no LAN relay
fallback, private IP, Local Network permission, or diagnostic network beacon
ships in the app binary. Bundled REAL dump (`FuelBoardDump` dataset, refreshed
by `scripts/refresh_bundled_dump.sh`) is the app's no-network last resort.
Local Network permission removed entirely — data page is informational, replay
on cellular can no longer hang. 107+ tests.
- [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
live in relay `.env` only, never in the IPA
+7 -5
View File
@@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.1</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -30,20 +30,22 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>2</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>FuelBoard can optionally connect to the FuelBoard Relay on your local network for faster alerts during development.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>FuelBoard uses Always location to alert you when you approach the cheapest station nearby.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.apt.fuelboard.smart-refresh</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>location</string>
<string>fetch</string>
</array>
<key>UILaunchScreen</key>
<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>
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.1</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>2</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
+649 -34
View File
@@ -1,7 +1,4 @@
// !$*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;
classes = {
@@ -10,10 +7,37 @@
objects = {
/* 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, ); }; };
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 */
/* 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 */ = {
isa = PBXContainerItemProxy;
containerPortal = BB0000000000000000000004 /* Project object */;
@@ -24,6 +48,28 @@
/* End PBXContainerItemProxy 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 */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@@ -38,11 +84,36 @@
/* End PBXCopyFilesBuildPhase 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; };
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 */
/* 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 */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = FuelBoard;
@@ -61,6 +132,20 @@
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
ADCE5B4C3033A329008C131C /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
ADCE5B6D3033A32E008C131C /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
BB000000000000000000000B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -75,16 +160,37 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
FBA000000000000000000302 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
FBA00000000000000000000B /* XCTest.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
AD64FC4B30739A4B008F27A9 /* Recovered References */ = {
isa = PBXGroup;
children = (
FBA00000000000000000010B /* XCTest.framework */,
);
name = "Recovered References";
sourceTree = "<group>";
};
BB000000000000000000000D = {
isa = PBXGroup;
children = (
FBA000000000000000000200 /* FuelBoardTests */,
AD22FCFF30682512009859D4 /* FuelBoardTips.storekit */,
BB0000000000000000000008 /* FuelBoard */,
BB0000000000000000000009 /* FuelBoardWidgets */,
BB000000000000000000000A /* Shared */,
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */,
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
BB000000000000000000000E /* Products */,
AD64FC4B30739A4B008F27A9 /* Recovered References */,
);
sourceTree = "<group>";
};
@@ -93,37 +199,95 @@
children = (
BB0000000000000000000007 /* FuelBoard.app */,
BB0000000000000000000002 /* FuelBoardWidgets.appex */,
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */,
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */,
AD64FC4C30739A4B008F27A9 /* FuelBoardSharedTests.xctest */,
);
name = Products;
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 */
/* Begin PBXNativeTarget section */
BB000000000000000000000F /* FuelBoard */ = {
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */ = {
isa = PBXNativeTarget;
buildConfigurationList = BB0000000000000000000010 /* Build configuration list for PBXNativeTarget "FuelBoard" */;
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App Extension" */;
buildPhases = (
BB0000000000000000000011 /* Sources */,
BB000000000000000000000B /* Frameworks */,
BB0000000000000000000012 /* Resources */,
BB0000000000000000000006 /* Embed App Extensions */,
ADCE5B4B3033A329008C131C /* Sources */,
ADCE5B4C3033A329008C131C /* Frameworks */,
ADCE5B4D3033A329008C131C /* Resources */,
);
buildRules = (
);
dependencies = (
BB0000000000000000000013 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
BB0000000000000000000008 /* FuelBoard */,
BB000000000000000000000A /* Shared */,
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
);
name = FuelBoard;
name = "FuelBoardWatch Watch App Extension";
packageProductDependencies = (
);
productName = FuelBoard;
productReference = BB0000000000000000000007 /* FuelBoard.app */;
productType = "com.apple.product-type.application";
productName = "FuelBoardWatch Watch App Extension";
productReference = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */;
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 */ = {
isa = PBXNativeTarget;
@@ -148,6 +312,50 @@
productReference = BB0000000000000000000002 /* FuelBoardWidgets.appex */;
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 */
/* Begin PBXProject section */
@@ -155,15 +363,23 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1600;
LastSwiftUpdateCheck = 2630;
LastUpgradeCheck = 1600;
TargetAttributes = {
BB000000000000000000000F = {
CreatedOnToolsVersion = 16.0;
ADCE5B4E3033A329008C131C = {
CreatedOnToolsVersion = 26.3;
};
BB0000000000000000000005 = {
CreatedOnToolsVersion = 16.0;
};
BB000000000000000000000F = {
CreatedOnToolsVersion = 16.0;
SystemCapabilities = {
com.apple.InAppPurchase = {
enabled = 1;
};
};
};
};
};
buildConfigurationList = BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */;
@@ -182,11 +398,28 @@
targets = (
BB000000000000000000000F /* FuelBoard */,
BB0000000000000000000005 /* FuelBoardWidgets */,
ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */,
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */,
FBA000000000000000000300 /* FuelBoardSharedTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
ADCE5B4D3033A329008C131C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
ADCE5B6E3033A32E008C131C /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
BB0000000000000000000012 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -201,9 +434,23 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
FBA000000000000000000303 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
ADCE5B4B3033A329008C131C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
BB0000000000000000000011 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -218,9 +465,36 @@
);
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 */
/* 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 */ = {
isa = PBXTargetDependency;
target = BB0000000000000000000005 /* FuelBoardWidgets */;
@@ -229,6 +503,266 @@
/* End PBXTargetDependency 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 = 26;
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 = 26;
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 = 26;
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 = 26;
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 */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -290,17 +824,20 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/App-Info.plist;
INFOPLIST_FILE = "Config/App-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
@@ -312,17 +849,20 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/App-Info.plist;
INFOPLIST_FILE = "Config/App-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
@@ -335,15 +875,16 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/Widget-Info.plist;
INFOPLIST_FILE = "Config/Widget-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -359,15 +900,16 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = F3BE6NE7U3;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Config/Widget-Info.plist;
INFOPLIST_FILE = "Config/Widget-Info.plist";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -377,14 +919,69 @@
};
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 */
/* Begin XCConfigurationList section */
BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */ = {
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App Extension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BB0000000000000000000018 /* Debug */,
BB0000000000000000000019 /* Release */,
ADCE5B573033A32B008C131C /* Debug */,
ADCE5B583033A32B008C131C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
ADCE5B6A3033A32D008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
ADCE5B6B3033A32D008C131C /* Debug */,
ADCE5B6C3033A32D008C131C /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@@ -407,6 +1004,24 @@
defaultConfigurationIsVisible = 0;
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 */
};
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>
+59 -31
View File
@@ -12,6 +12,9 @@ struct AlertsView: View {
@Binding var enabled: Bool
@Binding var radius: Double // stored in km (monitor + storage)
@Binding var fuel: FuelType // the fuel alerts monitor for
@Binding var alertMode: AlertMode
@Binding var bestValueThresholdGBP: Double
let bestValueConfigured: Bool
@Binding var favouriteDropEnabled: Bool
@Binding var favouriteDropFuel: FuelType
/// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
@@ -38,7 +41,7 @@ struct AlertsView: View {
/// "5 miles" or "8 km" — for the "Follow search" rows.
private var searchInUnitLabel: String {
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.
@@ -46,7 +49,7 @@ struct AlertsView: View {
/// what the option really means.
private func distanceLabel(forMiles miles: Int) -> String {
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
@@ -57,23 +60,23 @@ struct AlertsView: View {
private var alertRadiusGuidance: String {
switch alertChoice.wrappedValue {
case .followSearch:
return "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing)."
return String(localized: "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing).", comment: "Alert radius guidance for following search distance")
case .fixed(let value):
let optionLabel = "\(value) \(distanceUnit.label(for: Double(value)))"
let optionLabel = String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))
let body: String
switch value {
case 1:
body = "Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips."
body = String(localized: "Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips.", comment: "Alert radius guidance")
case 2:
body = "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town."
body = String(localized: "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town.", comment: "Alert radius guidance")
case 3:
body = "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns."
body = String(localized: "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns.", comment: "Alert radius guidance")
case 5:
body = "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."
body = String(localized: "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle.", comment: "Alert radius guidance")
default:
body = "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires."
body = String(localized: "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires.", comment: "Alert radius guidance")
}
return "\(optionLabel) — \(body)"
return String(format: String(localized: "%@ — %@", comment: "Alert radius guidance with option label"), optionLabel, body)
}
}
@@ -134,74 +137,99 @@ struct AlertsView: View {
NavigationStack {
List {
Section {
Toggle("Cheapest favourite", isOn: $favouriteDropEnabled)
Picker("Fuel", selection: $favouriteDropFuel) {
Toggle(String(localized: "Cheapest favourite", comment: "Favourite price-drop alert toggle"), isOn: $favouriteDropEnabled)
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $favouriteDropFuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
}
}
Button("Check now", action: onCheckFavouriteDropNow)
Button(String(localized: "Check now", comment: "Manual favourite price-drop check button"), action: onCheckFavouriteDropNow)
.disabled(!favouriteDropEnabled)
} header: {
Text("Price-drop alerts")
Text(String(localized: "Price-drop alerts", comment: "Favourite price-drop alerts section title"))
} footer: {
Text("Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise.")
Text(String(localized: "Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise.", comment: "Favourite price-drop alerts section footer"))
}
Section {
Toggle("Cheapest station", isOn: $enabled)
Picker("Fuel", selection: $fuel) {
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
Picker(String(localized: "Alert type", comment: "Proximity alert type picker title"), selection: $alertMode) {
ForEach(AlertMode.allCases.filter { mode in mode != .bestValue || bestValueConfigured }) { mode in
Text(mode.displayName).tag(mode)
}
}
if !bestValueConfigured {
Text(String(localized: "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.", comment: "Best Value alert unavailable hint"))
.font(.caption)
.foregroundStyle(.secondary)
}
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $fuel) {
ForEach(FuelType.allCases) { fuel in
Text(fuel.displayName).tag(fuel)
}
}
Picker("Radius", selection: alertChoice) {
Text("Follow search (\(searchInUnitLabel))").tag(AlertRadiusChoice.followSearch)
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("\(value) \(distanceUnit.label(for: Double(value)))").tag(AlertRadiusChoice.fixed(value))
Text(String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))).tag(AlertRadiusChoice.fixed(value))
}
}
Text(alertRadiusGuidance)
.font(.caption)
.foregroundStyle(.secondary)
if alertMode == .bestValue {
Picker(String(localized: "Minimum saving", comment: "Best Value alert threshold picker title"), selection: $bestValueThresholdGBP) {
ForEach(FuelStore.bestValueAlertThresholdOptionsGBP, id: \.self) { value in
Text(FuelSavingsFormat.money(value)).tag(value)
}
}
if !bestValueConfigured {
Text(String(localized: "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.", comment: "Best Value alert setup hint"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
if enabled && monitoredCount == 0 {
Text("No stations monitored yet — open the Stations tab to load prices first.")
Text(String(localized: "No stations monitored yet — open the Stations tab to load prices first.", comment: "No monitored stations hint"))
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Text("Proximity alerts")
Text(String(localized: "Proximity alerts", comment: "Proximity 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.")
let modeDescription = alertMode == .bestValue
? String(format: String(localized: "When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour.", comment: "Best Value proximity alerts section footer"), FuelSavingsFormat.money(bestValueThresholdGBP), fuel.displayName.lowercased())
: String(format: String(localized: "When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.", comment: "Proximity alerts section footer"), fuel.displayName.lowercased())
Text(modeDescription)
}
if enabled, let lastAlert {
Section("Last alert") {
Section(String(localized: "Last alert", comment: "Last alert section title")) {
Label(lastAlert, systemImage: "bell.badge.fill")
.font(.footnote)
}
}
Section {
Toggle("Cheapest station", isOn: $liveActivityEnabled)
Picker("Fuel", selection: $liveActivityFuel) {
Toggle(String(localized: "Cheapest station", comment: "Live Activity toggle"), isOn: $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) {
Text("Follow search (\(searchInUnitLabel))").tag(LADistanceChoice.followSearch)
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))
}
}
} 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.")
Text(String(localized: "Shows the cheapest station for the fuel and distance you pick here on the Lock Screen and Dynamic Island — independent of the Stations tab (choose Follow search to mirror its distance instead). Updates as you drive; tap to open directions. Live Activities can be disabled in System Settings → Live Activities.", comment: "Live Activity section footer"))
}
}
.navigationTitle("Alerts")
.navigationTitle(String(localized: "Alerts", comment: "Alerts tab title"))
}
}
}
@@ -1,38 +1,170 @@
{
"images" : [
"images": [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
"filename": "AppIcon.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances" : [
"appearances": [
{
"appearance" : "luminosity",
"value" : "dark"
"appearance": "luminosity",
"value": "dark"
}
],
"filename" : "AppIcon-dark.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
"filename": "AppIcon-dark.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
},
{
"appearances" : [
"appearances": [
{
"appearance" : "luminosity",
"value" : "tinted"
"appearance": "luminosity",
"value": "tinted"
}
],
"filename" : "AppIcon-tinted.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
"filename": "AppIcon-tinted.png",
"idiom": "universal",
"platform": "ios",
"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" : {
"author" : "xcode",
"version" : 1
"info": {
"author": "xcode",
"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

@@ -4,6 +4,14 @@
"filename" : "AppIconArtwork.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
@@ -4,6 +4,14 @@
"filename" : "AppIconPreview.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
@@ -4,6 +4,14 @@
"filename" : "AppIconPreviewDark.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
File diff suppressed because one or more lines are too long
@@ -4,6 +4,14 @@
"filename" : "PreviewWallpaper.jpg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_applegreen_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_applegreen_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_applegreen_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_applegreen_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
+14 -10
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_asda_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_asda_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_asda_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_asda_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
+14 -10
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_bp_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_bp_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_bp_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_bp_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_circle_k_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_circle_k_3x.png",
"idiom": "universal",
"scale": "3x"
"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
"info" : {
"author" : "xcode",
"version" : 1
}
}
+14 -10
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_essar_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_essar_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_essar_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_essar_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
+14 -10
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_esso_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_esso_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_esso_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_esso_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_gleaner_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_gleaner_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_gleaner_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_gleaner_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
+14 -10
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_gulf_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_gulf_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_gulf_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_gulf_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_highland_fuels_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_highland_fuels_3x.png",
"idiom": "universal",
"scale": "3x"
"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
"info" : {
"author" : "xcode",
"version" : 1
}
}
+14 -10
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_jet_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_jet_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_jet_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_jet_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
+14 -10
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_maxol_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_maxol_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_maxol_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_maxol_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_morrisons_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_morrisons_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_morrisons_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_morrisons_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
+14 -10
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_murco_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_murco_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_murco_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_murco_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_sainsburys_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_sainsburys_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_sainsburys_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_sainsburys_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
+14 -10
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_shell_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_shell_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_shell_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_shell_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
+14 -10
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_spar_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_spar_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_spar_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_spar_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
+14 -10
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_tesco_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_tesco_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_tesco_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_tesco_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
+14 -10
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_texaco_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_texaco_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_texaco_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_texaco_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_the_co_operative_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_the_co_operative_3x.png",
"idiom": "universal",
"scale": "3x"
"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
"info" : {
"author" : "xcode",
"version" : 1
}
}
+14 -10
View File
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_valero_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_valero_3x.png",
"idiom": "universal",
"scale": "3x"
"filename" : "brand_valero_2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "brand_valero_3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info": {
"author": "xcode",
"version": 1
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,18 +1,22 @@
{
"images": [
"images" : [
{
"filename": "brand_welcome_break_2x.png",
"idiom": "universal",
"scale": "2x"
"idiom" : "universal",
"scale" : "1x"
},
{
"filename": "brand_welcome_break_3x.png",
"idiom": "universal",
"scale": "3x"
"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
"info" : {
"author" : "xcode",
"version" : 1
}
}
+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: "Typical fill-up", 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)
}
}
File diff suppressed because it is too large Load Diff
+68 -10
View File
@@ -14,6 +14,18 @@ struct FavouritesView: View {
let location: Coordinate?
let distanceUnit: DistanceUnit
let priceDisplayStyle: PriceDisplayStyle
let stationTapAction: StationTapAction
/// Fuel Savings config. When it is configured this tab shows the SAME
/// fill-up control and per-row estimate as the Stations list: one session
/// amount is shared, so adjusting it in either list updates both, and it
/// still never touches the Settings default.
let fuelSavingsSettings: FuelSavingsSettings
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
/// Estimated saving for a favourite, against the nearest eligible station —
/// the same baseline and definition the Stations list uses.
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
var savingsBaselineStationName: String?
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
/// Persists a reordered favourites array (after drag-and-drop).
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
@@ -37,6 +49,9 @@ struct FavouritesView: View {
/// Trends sheet (price history chart) presentation state.
@State private var showTrends = false
/// Station picked for the More-info sheet (tap action = showDetails).
@State private var detailStation: FuelStation?
private var activeFuel: FuelType {
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
}
@@ -65,6 +80,12 @@ struct FavouritesView: View {
location: Coordinate?,
distanceUnit: DistanceUnit,
priceDisplayStyle: PriceDisplayStyle,
stationTapAction: StationTapAction,
fuelSavingsSettings: FuelSavingsSettings = .defaults,
fuelSavingsAmount: Binding<FuelSavingsFillAmount>,
savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil },
savingsBaselineStationName: String? = nil,
journeyDiagnosticsForStation: @escaping (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil },
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
onHistoryUnavailable: (() -> Void)? = nil,
@@ -74,6 +95,12 @@ struct FavouritesView: View {
self.location = location
self.distanceUnit = distanceUnit
self.priceDisplayStyle = priceDisplayStyle
self.stationTapAction = stationTapAction
self.fuelSavingsSettings = fuelSavingsSettings
_fuelSavingsAmount = fuelSavingsAmount
self.savingsForStation = savingsForStation
self.savingsBaselineStationName = savingsBaselineStationName
self.journeyDiagnosticsForStation = journeyDiagnosticsForStation
self.onToggleFavourite = onToggleFavourite
self.onReorder = onReorder
self.onHistoryUnavailable = onHistoryUnavailable
@@ -100,9 +127,9 @@ struct FavouritesView: View {
Image(systemName: "star")
.font(.system(size: 40))
.foregroundStyle(.secondary)
Text("No favourites yet")
Text(String(localized: "No favourites yet", comment: "Favourites empty-state title"))
.font(.headline)
Text("Tap the star on any station in the Stations tab to pin it here. The first station in each list becomes the favourite used in single widgets — drag to put your pick first.")
Text(String(localized: "Tap the star on any station in the Stations tab to pin it here. The first station in each list becomes the favourite used in single widgets — drag to put your pick first.", comment: "Favourites empty-state explanation"))
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -111,13 +138,24 @@ struct FavouritesView: View {
.padding(.vertical, 24)
}
} else {
Section("Fuel type") {
Section(String(localized: "Fuel type", comment: "Fuel type section title")) {
FuelTypeSegmentedPicker(
selection: $fuel,
fuels: availableFuels
)
}
if fuelSavingsSettings.isConfigured {
Section {
FuelSavingsAdjustmentView(
amount: $fuelSavingsAmount,
referencePricePencePerLitre: cheapestPrice
)
} footer: {
Text(String(localized: "Savings below are estimated against the nearest station with a price for this fuel — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation"))
}
}
Section {
ForEach(Array(ordered.enumerated()), id: \.element.id) { index, station in
StationRow(
@@ -127,28 +165,33 @@ struct FavouritesView: View {
distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle,
baselinePrice: cheapestPrice,
isTopResult: index == 0,
isTopResult: station.id == cheapest?.id,
isFavourite: activeFuelFavouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
topBadgeText: "CHEAPEST",
tapAction: stationTapAction,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
onShowDetails: { detailStation = $0 }
)
}
.onMove(perform: moveFavourite)
} header: {
Text("\(activeFuel.displayName) favourites")
Text(String(format: String(localized: "%@ favourites", comment: "Favourites section title for fuel type"), activeFuel.displayName))
} footer: {
Text("The first station is the favourite used in single widgets. Tap Edit, then drag to reorder.")
Text(String(localized: "The first station is the favourite used in single widgets. Tap Edit, then drag to reorder.", comment: "Favourites reorder explanation"))
}
if let cheapest = cheapest {
Section {
Text("\(cheapest.name) is your cheapest \(activeFuel.displayName) favourite. Favourites are monitored automatically — switch to the Alerts tab and enable alerts to get pinged when you approach one that's the cheapest within the radius.")
Text(String(format: String(localized: "%@ is your cheapest %@ favourite. Favourites are monitored automatically — switch to the Alerts tab and enable alerts to get pinged when you approach one that's the cheapest within the radius.", comment: "Cheapest favourite explanation"), cheapest.name, activeFuel.displayName))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Favourites")
.navigationTitle(String(localized: "Favourites", comment: "Favourites tab title"))
.toolbar {
if !favourites.isEmpty {
ToolbarItemGroup(placement: .topBarTrailing) {
@@ -156,7 +199,7 @@ struct FavouritesView: View {
showTrends = true
} label: {
Image(systemName: "chart.xyaxis.line")
.accessibilityLabel("Trends")
.accessibilityLabel(String(localized: "Trends", comment: "Trends button accessibility label"))
}
EditButton()
}
@@ -171,6 +214,21 @@ struct FavouritesView: View {
onHistoryRecovered: onHistoryRecovered
)
}
.sheet(item: $detailStation) { station in
StationDetailView(
station: station,
location: location,
distanceUnit: distanceUnit,
fuelSavings: savingsForStation(station),
fuelSavingsAmount: fuelSavingsAmount,
savingsBaselineStationName: savingsBaselineStationName,
journeyDiagnostics: journeyDiagnosticsForStation(station),
// Every station listed here IS a favourite for the tab's
// fuel, so the star starts filled; tapping it unstars.
isFavourite: true,
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
)
}
.onAppear {
#if DEBUG
// QA hook: launch with `-showTrends` to open the sheet
+88 -2
View File
@@ -1,13 +1,68 @@
import ActivityKit
import BackgroundTasks
import SwiftUI
@main
struct FuelBoardApp: App {
init() {
WatchSyncManager.shared.activate()
#if DEBUG
// QA hook (Debug builds only): `-qaLiveActivity e10|e5|diesel` starts a
// Live Activity with a long station name so the Lock Screen / island
// layout can be rendered in the Simulator for visual QA.
let args = ProcessInfo.processInfo.arguments
if let idx = args.firstIndex(of: "-qaLiveActivity"),
args.indices.contains(idx + 1),
let fuel = FuelType(rawValue: args[idx + 1]) {
startQALiveActivity(fuel: fuel)
}
#endif
}
var body: some Scene {
WindowGroup {
ContentView()
.onOpenURL(perform: handleOpenURL)
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchRefreshRequested)) { _ in
WatchSyncManager.shared.pushSnapshot()
}
}
.backgroundTask(.appRefresh(SmartDataRefreshScheduler.taskIdentifier)) {
await SmartDataRefreshCoordinator.runBackgroundProbe()
}
}
#if DEBUG
private func startQALiveActivity(fuel: FuelType) {
let state = FuelBoardLiveActivityAttributes.ContentState(
fuel: fuel,
stationID: "qa-phoenix",
stationName: "Phoenix Filling Stations",
brand: "Phoenix",
pricePence: 1499,
priceDisplayStyle: FuelStore.loadPriceDisplayStyle(),
distanceKM: 8.0,
// QA mock: marked approximate so the hook renders the LONGEST
// distance form ("Approx. 8.0 mi") and the island / Lock Screen
// layout can be checked against it.
distanceIsApproximate: true,
lat: 51.5,
lng: -0.12,
updatedAt: Date()
)
let attrs = FuelBoardLiveActivityAttributes()
do {
let activity = try Activity.request(
attributes: attrs,
content: .init(state: state, staleDate: nil),
pushType: nil
)
print("QA-LIVE-ACTIVITY STARTED id=\(activity.id)")
} catch {
print("QA-LIVE-ACTIVITY FAILED: \(error)")
}
}
#endif
/// Handles deep links that end up in the app. Widget taps arrive here in
/// two cases:
@@ -18,9 +73,40 @@ struct FuelBoardApp: App {
/// `http://maps.apple.com` form is also handled, from very old timelines.
/// In CarPlay the widget uses the same `maps://` URL, which routes to
/// Apple Maps without ever launching this app.
/// A `fuelboard://station?stationID=…&lat=…&lng=…&name=…` link (from a
/// widget or Live Activity tap when Settings → Tap station = More info)
/// posts a notification that ContentView observes to present the detail
/// sheet (falling back to the map sheet when the station has left
/// the cache).
private func handleOpenURL(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let items = components.queryItems
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { return }
if components.scheme == "fuelboard",
components.host == "station" {
let items = components.queryItems ?? []
var userInfo: [String: Any] = [:]
if let id = items.first(where: { $0.name == "stationID" })?.value {
userInfo["stationID"] = id
}
if let lat = items.first(where: { $0.name == "lat" })?.value.flatMap(Double.init) {
userInfo["stationLat"] = lat
}
if let lng = items.first(where: { $0.name == "lng" })?.value.flatMap(Double.init) {
userInfo["stationLng"] = lng
}
if let name = items.first(where: { $0.name == "name" })?.value {
userInfo["stationName"] = name
}
NotificationCenter.default.post(
name: .fuelBoardShowStationDetail,
object: nil,
userInfo: userInfo
)
return
}
guard let items = components.queryItems
else { return }
// Preferred: maps://?daddr=lat,lng&t=d (also covers http://maps.apple.com).
+228
View File
@@ -0,0 +1,228 @@
import SwiftUI
struct FuelSavingsAdjustmentView: View {
@Binding var amount: FuelSavingsFillAmount
let referencePricePencePerLitre: Double?
private var range: ClosedRange<Double> {
FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit)
}
private var step: Double {
FuelSavingsCalculator.fillAmountSliderStep(for: amount.unit)
}
/// Read-out of the figure the slider is on. Deliberately NOT a text field:
/// a typed figure could sit outside the slider's own bounds, leaving the
/// control and the value it claimed to show disagreeing. Slider-only means
/// what you see is the figure the estimate actually uses.
private var valueText: String {
switch amount.unit {
case .pounds: return String(format: "£%.0f", amount.value)
case .litres: return String(format: "%.0f L", amount.value)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("How much are you filling up?")
.font(.subheadline.weight(.semibold))
Picker("Fill-up unit", selection: unitBinding) {
ForEach(FuelSavingsFillUnit.allCases) { unit in
Text(unit.displayName).tag(unit)
}
}
.pickerStyle(.segmented)
HStack {
Slider(value: sliderBinding, in: range, step: step)
.accessibilityLabel("Fill-up amount")
.accessibilityValue(valueText)
Text(valueText)
.monospacedDigit()
.frame(width: 72, alignment: .trailing)
}
Text("Changes the estimate for this session only — your Settings default stays unchanged.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private var unitBinding: Binding<FuelSavingsFillUnit> {
Binding(
get: { amount.unit },
set: { newUnit in
guard newUnit != amount.unit else { return }
if let referencePricePencePerLitre,
let converted = try? FuelSavingsCalculator.convert(amount: amount, to: newUnit, referencePricePencePerLitre: referencePricePencePerLitre) {
amount = converted
} else {
amount = FuelSavingsFillAmount(value: newUnit == .pounds ? 40 : 30, unit: newUnit)
}
}
)
}
private var sliderBinding: Binding<Double> {
Binding(
get: { min(max(amount.value, range.lowerBound), range.upperBound) },
set: { amount.value = min(max($0, range.lowerBound), range.upperBound) }
)
}
}
struct FuelSavingsSummaryView: View {
let availability: FuelSavingsAvailability?
let amount: FuelSavingsFillAmount
let baselineStationName: String?