Author SHA1 Message Date
FuelBoard Contributor 3c83c9b518 fix: shorten report confirmation action 2026-09-17 22:30:25 +01:00
FuelBoard Contributor 6c8fd8b0de feat: add GOV.UK report issue action 2026-09-17 22:18:27 +01:00
FuelBoard Contributor 44cbd3b1e4 feat: route all Live Activity island taps through Tap station action
Expanded leading/trailing, compact and minimal island views had
no Link, so tapping them just opened the app with no destination.
Every island region now links to the same tap destination as the
Lock Screen card (Maps vs in-app detail).

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

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

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

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

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

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

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

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

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

Also darken the pump circle to 82% of the raw fuel tint (mix 18% black) so
the white pump symbol keeps contrast against bright Lock Screen/card, esp.
for the light yellow premium / cyan diesel palette colours.
2026-08-19 22:26:37 +01:00
FuelBoard Contributor 2c6e63f6b1 live activity: tint pump glyph by monitored fuel
Move FuelType.tintColor (green=unleaded #30D158, yellow=premium #FFD60A,
cyan=diesel #64D2FF) into Shared/FuelStore.swift so the app, widget, and
Live Activity all read one definition (was app-only in StationsView, so the
widget couldn't see it). Tint the Live Activity rich card pump icon and the
Dynamic Island compactLeading pump by context.state.fuel.tintColor.
2026-08-19 22:18:56 +01:00
FuelBoard Contributor a6620d36f7 live activity: select rich vs compact by actual GeometryReader width
ViewThatFits measures each child's IDEAL (untruncated) width to decide
fit, and a flexible .frame(maxWidth:.infinity) middle column does not cap
that ideal — so a long station name (e.g. 5-mi Phoenix Filling Stations)
inflated richBody's ideal past the iPhone Lock Screen width and fell back
to the compact strip. Replace the ViewThatFits branch with a
GeometryReader that reads the ACTUAL proposed width and shows the full
three-column card at/above 280pt, compact below. Content-independent, so a
long name can no longer collapse the iPhone card.
2026-08-19 22:08:07 +01:00
FuelBoard Contributor 227cce4aa6 live activity: fix compact strip string interpolation (double backslash)
The compact CarPlay/Watch strip's second line had escaped interpolation
(\\( instead of \\() so it rendered the literal placeholder text
"context.state.stationName · context.state.distanceText" instead of the
actual station name and distance. Normalize to single-backslash interpolation.
2026-08-19 20:01:14 +01:00
FuelBoard Contributor 01bb3ba905 live activity: compact CarPlay strip shows fuel + price + station distance
Restore .supplementalActivityFamilies([.small]) + a ViewThatFits compact
fallback so CarPlay's small slot renders our shared compact strip (fuel type,
bold price, station · distance) instead of falling back to the Dynamic Island
compact closure, which showed the app name and omitted the distance the user
wants. Full iPhone/iPad card is preserved via the richMinWidth gate on
richBody plus its flexible, truncating middle column (long names can no longer
collapse it).
2026-08-19 18:31:36 +01:00
FuelBoard Contributor e586e5ecbf Merge main into feature/watch-app
Bring watch branch up to date with main (banner fade, onboarding timing,
Live Activity full-width layout). Resolved the bundled fuel dump data-refresh
conflict by taking main's newer snapshot (data_updated 2026-08-17T15:01, 8028
stations).
2026-08-19 18:11:13 +01:00
FuelBoard Contributor 5d26509034 live activity: always render full card (drop compact fallback)
Long station names inflated richBody's IDEAL width, so ViewThatFits (which
measures ideal, untruncated text) decided 'rich doesn't fit' and fell back to
the compact strip — that's why 5-mi / long-named activities rendered small
while 10-15-mi / short names stayed full. With .supplementalActivityFamilies
already removed, the body is only ever Lock-Screen width, so drop ViewThatFits
and compactBody entirely and always render the full three-column card; the
station caption line-limits + scale-downs + tail-truncates in place.
2026-08-19 16:39:49 +01:00
FuelBoard Contributor 221b96097d live activity: drop .small family so iPhone/iPad show full-width card
The active activity was rendering the narrow .small form on the iPhone Lock
Screen (squeezed, small text) because .supplementalActivityFamilies([.small])
made iOS eligible to present it that way. Remove it so iPhone/iPad always get
the full three-column card; CarPlay's small form still comes from the Dynamic
Island compact closures. Keep compactBody as a narrow-width safety fallback.
2026-08-19 16:09:02 +01:00
FuelBoard Contributor b012232012 live activity: robust full-vs-compact split; scale long station names
The adaptive layout picks the full three-column view when there's Lock
Screen width and a compact price strip in the small CarPlay/Watch slot.
Tighten the discriminant (rich ≥290pt, compact ≤220pt) so iPhone/iPad
always get the full view and only genuinely small slots get the compact
strip. Long station names used to clip at inconsistent points across
the rich/compact/Dynamic Island views; both bodies now scale + tail-
truncate so the caption stays consistent regardless of station name.
2026-08-19 13:58:54 +01:00
FuelBoard Contributor 727ef6e58f banner: explicit opacity fade (transitions were unreliable)
SwiftUI's removal transition never actually played despite .id keying — the
tip banner vanished instantly on dismiss. Replace it with explicit opacity/
offset driven by withAnimation in reflectBanner (gated by a .task(id:
activeBanner) so presentation is reliable and prompt): a dismissal now
fades out + slides up over 0.35s, and a tip→network swap crossfades. Uses
the same state-driven pattern that fixed the HermesCall ticker fades.
2026-08-19 11:57:32 +01:00
FuelBoard Contributor 4c222cdf55 banner: key by value so tip fades out on dismiss
The banner sits in a single if-let slot; without an identity key, clearing a
tip that was covering a network/offline banner swapped content in place with
no transition (instantly vanishing). Keying the banner by its value (.id)
makes every change — tip→network or tip→nil — run the shared removal
transition, so the tip slides up and fades out when it auto-dismisses or is
tapped.
2026-08-19 11:10:29 +01:00
FuelBoard Contributor cfd435d12a banner: top-floating; fix offline-trigger cache; onboarding prompt timing
- Banner floats at the very top (over the nav/title area), never pushing the
  content below and staying clear of the list/pill.
- Offline banner now fires on refresh in airplane mode / no data: the mirror
  live chain bypassed the HTTP cache (+10s timeout) so an offline fetch really
  fails instead of silently re-serving a cached pointer+dump as a 'success'
  (which kept dataStatus .live and hid the banner).
- Onboarding permissions fire at the Continue/Allow tap BEFORE advancing, so
  the system prompt appears after the page is read and never covers the next
  page's animation (grant auto-advances).
2026-08-19 10:17:24 +01:00
FuelBoard Contributor 8936643e0e banner: float below nav bar (no glass pass-through) + slide-up dismiss
Position the floating banner just below the nav bar so it no longer overlays the iOS 26 Liquid Glass back/toolbar chrome behind it — tapping retry can no longer also select the control beneath. Banner now slides up + fades on dismiss/timeout (one style for all notifications); full-rect contentShape for a solid tap target.
2026-08-19 08:43:35 +01:00
FuelBoard Contributor ba83f9e1c0 banner: unify network+tip into ONE floating overlay (never layout-shifting)
Extract AppBanner; render offline/connection/tip through the single statusBannerCard chrome as a ZStack top overlay so it floats over content instead of shifting layout. Tip (auto-dismiss ~3.5s) takes precedence over network state.
2026-08-19 08:17:50 +01:00
FuelBoard Contributor 57dffb6c5e tip: show outcome in the shared status banner (auto-dismiss)
Tip purchase results now ride the SAME banner chrome as the network/offline
strip above the tabs instead of a hard-to-see Settings footnote. TipStore is
lifted to ContentView (the banner owner) and shared down to Settings. Each
outcome maps to an icon/tint; auto-dismisses after ~3.5 s; tap to dismiss.
Renderer refactored into a shared statusBannerCard(icon:tint:title:subtitle:
trailingIcon:action:) used by both the network strip (retry) and tips (x).
2026-08-19 07:48:10 +01:00
FuelBoard Contributor 2205a585f1 onboarding: animated feature icons 2026-08-19 07:09:54 +01:00
FuelBoard Contributor f9b70dbb9d anim: consistent bold crossfade in fuel-type picker 2026-08-19 07:09:54 +01:00
FuelBoard Contributor e00e3ff365 backlog: refine foresight live activity notes 2026-08-18 19:33:54 +01:00
FuelBoard Contributor 4f8bf96ca2 backlog: add foresight live activity investigation 2026-08-18 18:15:33 +01:00
FuelBoard Contributor 8945190da5 fix: keep live activity in sync with motion 2026-08-18 17:59:03 +01:00
FuelBoard Contributor e004f25a31 feat: add real watch companion and dual IPA build workflow 2026-08-17 22:41:12 +01:00
97 changed files with 3615 additions and 265 deletions
+1
View File
@@ -1,6 +1,7 @@
build/
build-release/
build-sim/
build-watch/
.DS_Store
*.xcuserstate
xcuserdata/
+19
View File
@@ -144,6 +144,25 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
separate phase. Backlog ancestry: this was the original "relay could keep a
rolling archive" idea — the archive is now P0 and the Trends graph (P1) is its
visible half.
- [ ] **Foresight Live Activity (heading-aware next station)** — investigate a
driving-time surface that predicts the likely next station ahead of the user
from cached data only (no extra source-data refresh on motion). Best v1 shape:
Live Activity option, not widget-first, because widget space/refresh budgets
are tight and the lock-screen/CarPlay compact slot better matches the use case.
Prefer ONE FuelBoard Live Activity with a mode selector (e.g. `Nearby cheapest`
vs `Foresight / next ahead`) rather than trying to run two simultaneous
FuelBoard activities; iOS can support multiple activities, but FuelBoard's
current implementation is single-activity and the compact surfaces are too
constrained for two competing driving modes anyway. Viable MVP: infer heading
from recent motion/course, keep only stations in a forward cone, score
candidates by forward distance + lateral offset, and show the next-ahead
station with all three fuel prices or a compact rotating/focused fuel
treatment if space allows. Key caveat: heading is not full route awareness,
so avoid overpromising "the next station you will reach" on motorways/opposite
carriageways; frame as "next ahead" / "likely next stop" unless route-quality
heuristics improve. Must include hysteresis/speed thresholds to avoid flapping,
and should be tested specifically against compact Live Activity/CarPlay layouts
where space is extremely limited.
- [ ] **Widget sparkline** — medium-widget mini sparkline of a favourite's 30-day
trend, after the Trends UI lands (one-kind rule preserved).
- [ ] **Price alerts for a specific station** — notify when a starred station's
+6 -1
View File
@@ -30,7 +30,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>2</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
@@ -41,8 +41,13 @@
<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>fetch</string>
<string>location</string>
</array>
<key>UILaunchScreen</key>
+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>
+464 -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,26 @@
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, ); }; };
/* 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 +37,28 @@
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
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;
};
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;
};
BB0000000000000000000006 /* Embed App Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
@@ -38,11 +73,23 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
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; };
/* 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 +108,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;
@@ -84,6 +145,8 @@
BB0000000000000000000008 /* FuelBoard */,
BB0000000000000000000009 /* FuelBoardWidgets */,
BB000000000000000000000A /* Shared */,
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */,
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
BB000000000000000000000E /* Products */,
);
sourceTree = "<group>";
@@ -93,6 +156,8 @@
children = (
BB0000000000000000000007 /* FuelBoard.app */,
BB0000000000000000000002 /* FuelBoardWidgets.appex */,
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */,
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -100,30 +165,50 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
BB000000000000000000000F /* FuelBoard */ = {
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App */ = {
isa = PBXNativeTarget;
buildConfigurationList = BB0000000000000000000010 /* Build configuration list for PBXNativeTarget "FuelBoard" */;
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */;
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 +233,33 @@
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";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -155,15 +267,18 @@
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;
};
};
};
buildConfigurationList = BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */;
@@ -182,11 +297,27 @@
targets = (
BB000000000000000000000F /* FuelBoard */,
BB0000000000000000000005 /* FuelBoardWidgets */,
ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */,
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */,
);
};
/* 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;
@@ -204,6 +335,13 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
ADCE5B4B3033A329008C131C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
BB0000000000000000000011 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -221,6 +359,16 @@
/* 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 +377,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_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
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;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0.1;
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_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
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;
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0.1;
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 = 3;
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.0.1;
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 = 3;
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.0.1;
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 = {
@@ -291,14 +699,15 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 3;
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.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -313,14 +722,15 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 3;
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.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -335,15 +745,16 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 3;
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.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -359,15 +770,16 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 3;
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.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -380,11 +792,20 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */ = {
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = {
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 +828,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */ = {
isa = XCConfigurationList;
buildConfigurations = (
BB0000000000000000000018 /* Debug */,
BB0000000000000000000019 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = BB0000000000000000000004 /* Project object */;
@@ -29,6 +29,138 @@
"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": {
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

+266 -58
View File
@@ -2,15 +2,26 @@ import SwiftUI
import CoreLocation
import WidgetKit
/// The single floating notification banner. Network/offline state and tip
/// outcomes all funnel through ONE style and render via the same chrome in
/// ContentView — never pushing layout, always overlaid.
enum AppBanner: Hashable {
case offlineDump(date: String)
case connectionProblem
case tip(TipStore.TipOutcome)
}
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var stations: [FuelStation] = FuelStore.loadStations()
@State private var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
@State private var dataRefreshMode: DataRefreshMode = FuelStore.loadDataRefreshMode()
@State private var sortMode: SortMode = FuelStore.loadSortMode()
@State private var stationLimit: Int = FuelStore.loadStationLimit()
@State private var distanceUnit: DistanceUnit = FuelStore.loadDistanceUnit()
@State private var priceDisplayStyle: PriceDisplayStyle = FuelStore.loadPriceDisplayStyle()
@State private var stationTapAction: StationTapAction = FuelStore.loadStationTapAction()
@State private var favourites: [FavouriteEntry] = FuelStore.loadFavourites()
@State private var alertsEnabled: Bool = FuelStore.loadAlertsEnabled()
@State private var alertsRadius: Double = FuelStore.loadAlertsRadius()
@@ -32,6 +43,41 @@ struct ContentView: View {
private var effectiveAlertsRadiusKM: Double {
FuelStore.effectiveAlertsRadiusKM(followsSearch: alertsFollowsSearch, manualKM: alertsRadius)
}
private func installLocationUpdateHook() {
locationManager.onLocationUpdate = { [weak monitor] in
monitor?.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
// The Live Activity follows the same wake-ups — this hook
// fires on every fix incl. background significant-change
// wake-ups, so the Lock Screen pill stays live while driving.
updateLiveActivity()
refreshRoadDistancesIfNeeded()
}
}
/// Kicks off a (throttled) Apple-Maps road-distance recompute for the
/// stations around the current fix. The app owns routing — the widget and
/// Live Activity only read the cached result. Falls back to the last saved
/// location so it can run before the first fresh GPS fix arrives.
private func refreshRoadDistancesIfNeeded() {
let origin = location ?? FuelStore.loadLocation()
guard let origin else { return }
Task {
await RoadDistanceService.refreshIfNeeded(
stations: stations,
lat: origin.lat,
lng: origin.lng
)
}
}
private func ensureBackgroundMotionTrackingIfNeeded() {
if alertsEnabled || liveActivityEnabled {
locationManager.startBackgroundTracking()
}
}
@State private var isLoading = false
@State private var statusMessage = ""
/// What data is on screen, driving which (if any) status banner shows
@@ -52,6 +98,20 @@ struct ContentView: View {
@State private var selectedTab = 0
@State private var locationManager = LocationManager()
@StateObject private var monitor = ProximityMonitor()
/// Owns tip purchases; the single instance is shared down to Settings.
/// Its transient outcome rides the SAME status-banner chrome as the
/// network/offline strip (ContentView renders it above the tabs).
@StateObject private var tipStore = TipStore()
// --- Explicit banner animation state (robust fade-out) ---
// SwiftUI's removal transition for the banner proved unreliable here
// (the tip faded to nothing only in the cross-nil case, and even then
// inconsistently). Drive opacity/offset explicitly instead so a
// dismissal ALWAYS fades + slides up on ANY banner change.
@State private var currentBanner: AppBanner?
@State private var bannerOpacity: Double = 0
@State private var bannerOffset: CGFloat = 0
@State private var bannerClearTask: Task<Void, Never>?
/// The pool the list draws from. In Cheapest mode the chosen miles radius
/// bounds it ("best price within X miles"); in Closest mode the radius is
@@ -175,6 +235,30 @@ struct ContentView: View {
.sheet(item: $monitor.pendingStationMap) { request in
StationMapView(request: request)
}
.sheet(item: $monitor.pendingDetailStation) { station in
StationDetailView(
station: station,
location: location,
distanceUnit: distanceUnit
)
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardShowStationDetail)) { note in
let info = note.userInfo ?? [:]
if let id = info["stationID"] as? String,
let station = stations.first(where: { $0.id == id }) {
monitor.pendingDetailStation = station
return
}
// Station left the cache — fall back to the map sheet so
// the tap still shows something.
if let lat = info["stationLat"] as? Double,
let lng = info["stationLng"] as? Double {
let name = info["stationName"] as? String ?? "Station"
monitor.pendingStationMap = StationMapRequest(
name: name, latitude: lat, longitude: lng
)
}
}
)
}
@@ -217,6 +301,7 @@ struct ContentView: View {
}
if !favs.isEmpty {
FuelStore.saveFavourites(favs)
WatchSyncManager.shared.pushSnapshot()
}
}
if let i = args.firstIndex(of: "-tab"), i + 1 < args.count {
@@ -263,27 +348,23 @@ struct ContentView: View {
#else
let shouldSkipOnboarding = false
#endif
if FuelStore.loadHasCompletedOnboarding()
if !FuelStore.shouldShowOnboarding()
|| shouldSkipOnboarding {
locationManager.startForegroundTracking()
// Geofences must follow the user even in the background:
// wire the delegate hook (fires on every fix incl. background
// significant-change wake-ups) to re-register the region
// window around the new position. SwiftUI onChange alone
// never runs while suspended, so alerts would otherwise stay
// frozen around the last foreground fix.
locationManager.onLocationUpdate = { [weak monitor] in
monitor?.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
// The Live Activity follows the same wake-ups — this hook
// fires on every fix incl. background significant-change
// wake-ups, so the Lock Screen pill stays live while driving.
updateLiveActivity()
}
// Geofences and the Live Activity must follow the user even in
// the background: wire the delegate hook (fires on every fix
// incl. background significant-change wake-ups) so SwiftUI's
// foreground-only onChange isn't the only update path.
installLocationUpdateHook()
ensureBackgroundMotionTrackingIfNeeded()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
monitor.setEnabled(alertsEnabled)
updateLiveActivity()
// Compute road distances early (throttled; falls back to the
// last saved location) so distance surfaces are road-matched
// as soon as stations are available.
refreshRoadDistancesIfNeeded()
// Refresh only when the cache is stale (twice-a-day policy).
// Skipped under the force-* hooks so the banner stays up.
#if DEBUG
@@ -310,6 +391,8 @@ struct ContentView: View {
// empty on a brand-new install.
if !showing, FuelStore.loadHasCompletedOnboarding() {
locationManager.startForegroundTracking()
installLocationUpdateHook()
ensureBackgroundMotionTrackingIfNeeded()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity()
@@ -327,7 +410,11 @@ struct ContentView: View {
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity()
// No network fetch on foreground — pull-to-refresh is the override.
refreshRoadDistancesIfNeeded()
if FuelStore.hasPendingWatchRefreshRequest {
Task { await handlePendingWatchRefreshRequest() }
}
// No network fetch on foreground unless the watch explicitly requests one.
} else {
locationManager.stopForegroundTracking()
}
@@ -336,12 +423,14 @@ struct ContentView: View {
if let newLocation {
location = newLocation
FuelStore.saveLocation(lat: newLocation.lat, lng: newLocation.lng)
WatchSyncManager.shared.pushSnapshot()
WidgetCenter.shared.reloadAllTimelines()
// Geofences follow the user's position, but the station list is
// NOT re-fetched on every movement (cached, twice-a-day policy).
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
updateLiveActivity()
refreshRoadDistancesIfNeeded()
}
}
)
@@ -353,6 +442,7 @@ struct ContentView: View {
.onChange(of: selectedFuel) { _, _ in
// No re-fetch needed — one response carries E5/E10/DIESEL prices.
WidgetCenter.shared.reloadAllTimelines()
WatchSyncManager.shared.pushSnapshot()
}
.onChange(of: stationLimit) { _, newValue in
// Distance filter is LOCAL math now — the cache holds the full-UK
@@ -373,9 +463,7 @@ struct ContentView: View {
monitor.setEnabled(newValue)
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
if newValue {
locationManager.startBackgroundTracking()
}
ensureBackgroundMotionTrackingIfNeeded()
}
.onChange(of: alertsRadius) { _, newValue in
FuelStore.saveAlertsRadius(newValue)
@@ -406,6 +494,7 @@ struct ContentView: View {
// Toggling the Live Activity on starts it with the current best
// station; toggling off ends any running activity.
FuelStore.saveLiveActivityEnabled(newValue)
ensureBackgroundMotionTrackingIfNeeded()
updateLiveActivity()
}
.onChange(of: liveActivityFuel) { _, newValue in
@@ -444,38 +533,91 @@ struct ContentView: View {
)
}
private var mainContent: some View {
VStack(spacing: 0) {
statusBannerView
rootTabView
/// The one banner to show right now. Network/offline and tip outcomes all
/// funnel here and render through the SAME floating chrome. A transient
/// tip (auto-dismissed by TipStore) takes precedence over the persistent
/// network state for its ~3.5 s.
private var activeBanner: AppBanner? {
if let outcome = tipStore.outcome { return .tip(outcome) }
switch dataStatus {
case .live: return nil
case .offlineDump(let date): return .offlineDump(date: date)
case .connectionProblem: return .connectionProblem
}
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: dataStatus)
}
@ViewBuilder
private var statusBannerView: some View {
switch dataStatus {
case .offlineDump(let date):
let title = offlineTitle(date: date)
statusBanner(
icon: "wifi.slash",
tint: .orange,
title: title,
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
accessibilityLabel: date.isEmpty
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
)
case .connectionProblem:
statusBanner(
icon: "wifi.exclamationmark",
tint: .red,
title: NSLocalizedString("Check your internet connection", comment: ""),
subtitle: NSLocalizedString("Tap to try again", comment: ""),
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
)
case .live:
EmptyView()
private var mainContent: some View {
GeometryReader { geo in
ZStack(alignment: .top) {
rootTabView
if let banner = currentBanner {
// Floating near the top of the screen, over the nav area —
// overlays content (never pushes it) and sits above the
// main content so it doesn't cover or block the list/pill
// beneath it. Opacity/offset are driven explicitly by
// reflectBanner so the fade-out reliably animates.
floatingBanner(banner)
.padding(.top, geo.safeAreaInsets.top + 10)
.opacity(bannerOpacity)
.offset(y: bannerOffset)
}
}
}
.task(id: activeBanner) {
// Fires on launch with the current banner AND whenever it changes —
// unlike onChange(computed) which can miss the first non-nil value
// when dataStatus is set slightly after the view appears.
reflectBanner(activeBanner)
}
}
/// Explicitly animates the banner in/out — a robust replacement for the
/// SwiftUI removal transition (which was silently not firing). Handles:
/// - first appearance → fade + settle down from slightly above
/// - any content change (tip→network, new tip) → fade the old out, then
/// fade the new in — so a tip ALWAYS visibly fades away
/// - dismissal → fade out + slide up, then clear after the fade
private func reflectBanner(_ newBanner: AppBanner?) {
guard newBanner != currentBanner else { return }
bannerClearTask?.cancel()
if let newBanner {
if currentBanner == nil {
present(newBanner)
} else {
// Crossfade: slide+fade the current out, then present the new.
withAnimation(.easeOut(duration: 0.2)) {
bannerOpacity = 0
bannerOffset = -28
}
bannerClearTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 250_000_000)
guard !Task.isCancelled else { return }
present(newBanner)
}
}
} else {
withAnimation(.easeOut(duration: 0.35)) {
bannerOpacity = 0
bannerOffset = -28
}
bannerClearTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 350_000_000)
guard !Task.isCancelled else { return }
currentBanner = nil
bannerOpacity = 0
bannerOffset = 0
}
}
}
/// Fade a banner in from slightly above and settle it into place.
private func present(_ banner: AppBanner) {
currentBanner = banner
bannerOpacity = 0
bannerOffset = -16
withAnimation(.easeOut(duration: 0.3)) {
bannerOpacity = 1
bannerOffset = 0
}
}
@@ -506,13 +648,51 @@ struct ContentView: View {
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
}
/// Shared status-strip chrome: a tappable card pinned above the tabs.
/// Tapping retries the live fetch from ANY screen — no pull gesture
/// needed, so the offline banner isn't trapped on the Stations tab.
private func statusBanner(icon: String, tint: Color, title: String, subtitle: String, accessibilityLabel: String) -> some View {
Button {
/// ONE floating banner style for all notifications. Network/offline and
/// tip outcomes share the same chrome; only the tap action differs —
/// network → retry the fetch, tip → dismiss (TipStore auto-dismisses too).
@ViewBuilder
private func floatingBanner(_ banner: AppBanner) -> some View {
switch banner {
case .offlineDump(let date):
let title = offlineTitle(date: date)
statusBannerCard(
icon: "wifi.slash", tint: .orange, title: title,
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
trailingIcon: "arrow.clockwise",
accessibilityLabel: date.isEmpty
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
) {
Task { await refresh(force: true) }
} label: {
}
case .connectionProblem:
statusBannerCard(
icon: "wifi.exclamationmark", tint: .red,
title: NSLocalizedString("Check your internet connection", comment: ""),
subtitle: NSLocalizedString("Tap to try again", comment: ""),
trailingIcon: "arrow.clockwise",
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
) {
Task { await refresh(force: true) }
}
case .tip(let outcome):
statusBannerCard(
icon: outcome.icon, tint: outcome.tint, title: outcome.message, subtitle: nil,
trailingIcon: "xmark", accessibilityLabel: outcome.message
) {
withAnimation(.easeOut(duration: 0.2)) { tipStore.dismissOutcome() }
}
}
}
/// The single floating-banner chrome shared by every notification
/// (network/offline + tip). Overlaid — never in flow — so it never moves
/// the content beneath it.
private func statusBannerCard(icon: String, tint: Color, title: String, subtitle: String?,
trailingIcon: String, accessibilityLabel: String,
action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 10) {
Image(systemName: icon)
.font(.system(size: 17, weight: .semibold))
@@ -522,12 +702,14 @@ struct ContentView: View {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
if let subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "arrow.clockwise")
Image(systemName: trailingIcon)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(tint)
}
@@ -540,13 +722,13 @@ struct ContentView: View {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(tint.opacity(0.35), lineWidth: 1)
)
.shadow(color: .black.opacity(0.08), radius: 8, y: 3)
)
.padding(.horizontal, 12)
.padding(.bottom, 6)
}
.contentShape(Rectangle())
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel)
.transition(.move(edge: .top).combined(with: .opacity))
}
/// Pushes the current best-in-radius station into the Live Activity.
@@ -585,6 +767,7 @@ struct ContentView: View {
topStationID: topStationID,
location: location,
favouriteIDs: favouriteIDs,
stationTapAction: stationTapAction,
onToggleFavourite: toggleFavourite,
onRefresh: { await refresh(force: true) }
)
@@ -599,6 +782,7 @@ struct ContentView: View {
location: location,
distanceUnit: distanceUnit,
priceDisplayStyle: priceDisplayStyle,
stationTapAction: stationTapAction,
onToggleFavourite: toggleFavourite,
onReorder: reorderFavourites,
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
@@ -634,8 +818,11 @@ struct ContentView: View {
/// within the compiler's type-check budget.
private var settingsTab: some View {
SettingsView(
tipStore: tipStore,
distanceUnit: $distanceUnit,
priceDisplayStyle: $priceDisplayStyle,
stationTapAction: $stationTapAction,
dataRefreshMode: $dataRefreshMode,
alertsFuel: alertsFuel,
alertsRadiusKM: alertsRadius,
testAlertResult: monitor.lastTestResult,
@@ -664,6 +851,7 @@ struct ContentView: View {
favourites.append(key)
}
FuelStore.saveFavourites(favourites)
WatchSyncManager.shared.pushSnapshot()
WidgetCenter.shared.reloadAllTimelines()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
@@ -675,11 +863,17 @@ struct ContentView: View {
private func reorderFavourites(_ newOrder: [FavouriteEntry]) {
favourites = newOrder
FuelStore.saveFavourites(favourites)
WatchSyncManager.shared.pushSnapshot()
WidgetCenter.shared.reloadAllTimelines()
monitor.update(stations: stations, favourites: refreshedFavourites,
fuel: alertsFuel, radiusKM: effectiveAlertsRadiusKM)
}
private func handlePendingWatchRefreshRequest() async {
guard FuelStore.hasPendingWatchRefreshRequest else { return }
await refresh(force: true)
}
/// Fetches fresh prices, but only when the cache is stale — unless
/// `force` is true (pull-to-refresh is the manual override).
private func refresh(force: Bool = false) async {
@@ -698,6 +892,10 @@ struct ContentView: View {
stations = fetched
FuelStore.saveStations(fetched)
FuelStore.saveLastRefresh()
if force, FuelStore.hasPendingWatchRefreshRequest {
FuelStore.markWatchRefreshHandled()
}
WatchSyncManager.shared.pushSnapshot()
// Persist envelope metadata (source, station count, GOV.UK
// dataset update time) for the Settings → About section — the
// live chain records whichever leg served the fetch.
@@ -720,6 +918,9 @@ struct ContentView: View {
// Live data restored — any status banner no longer applies.
dataStatus = .live
} catch {
if force, FuelStore.hasPendingWatchRefreshRequest {
FuelStore.markWatchRefreshHandled()
}
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
if FuelStore.loadStations().isEmpty {
// No cached prices — last resort is the bundled REAL dump
@@ -758,7 +959,9 @@ struct StationRow: View {
let baselinePrice: Double?
let isTopResult: Bool
let isFavourite: Bool
var tapAction: StationTapAction = .openMap
var onToggleFavourite: () -> Void = {}
var onShowDetails: (FuelStation) -> Void = { _ in }
private var ragColor: Color {
guard let price = station.prices[fuel], let baselinePrice else { return .gray }
@@ -821,7 +1024,7 @@ struct StationRow: View {
.lineLimit(1)
.truncationMode(.tail)
if let location {
Text(distanceUnit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
Text(distanceUnit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
.font(.caption2)
.foregroundStyle(.secondary)
.monospacedDigit()
@@ -857,9 +1060,14 @@ struct StationRow: View {
}
.contentShape(Rectangle())
.onTapGesture {
switch tapAction {
case .openMap:
if let url = station.mapsDirectionsURL {
UIApplication.shared.open(url)
}
case .showDetails:
onShowDetails(station)
}
}
}
}
+16 -1
View File
@@ -14,6 +14,7 @@ struct FavouritesView: View {
let location: Coordinate?
let distanceUnit: DistanceUnit
let priceDisplayStyle: PriceDisplayStyle
let stationTapAction: StationTapAction
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
/// Persists a reordered favourites array (after drag-and-drop).
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
@@ -37,6 +38,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 +69,7 @@ struct FavouritesView: View {
location: Coordinate?,
distanceUnit: DistanceUnit,
priceDisplayStyle: PriceDisplayStyle,
stationTapAction: StationTapAction,
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
onHistoryUnavailable: (() -> Void)? = nil,
@@ -74,6 +79,7 @@ struct FavouritesView: View {
self.location = location
self.distanceUnit = distanceUnit
self.priceDisplayStyle = priceDisplayStyle
self.stationTapAction = stationTapAction
self.onToggleFavourite = onToggleFavourite
self.onReorder = onReorder
self.onHistoryUnavailable = onHistoryUnavailable
@@ -129,7 +135,9 @@ struct FavouritesView: View {
baselinePrice: cheapestPrice,
isTopResult: index == 0,
isFavourite: activeFuelFavouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
tapAction: stationTapAction,
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
onShowDetails: { detailStation = $0 }
)
}
.onMove(perform: moveFavourite)
@@ -171,6 +179,13 @@ struct FavouritesView: View {
onHistoryRecovered: onHistoryRecovered
)
}
.sheet(item: $detailStation) { station in
StationDetailView(
station: station,
location: location,
distanceUnit: distanceUnit
)
}
.onAppear {
#if DEBUG
// QA hook: launch with `-showTrends` to open the sheet
+84 -2
View File
@@ -1,13 +1,64 @@
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,
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 +69,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).
+1 -1
View File
@@ -57,7 +57,7 @@ enum LiveActivityManager {
brand: best.brand,
pricePence: price,
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
distanceKM: best.distanceKM(to: location.lat, lng2: location.lng),
distanceKM: FuelStore.displayDistanceKM(station: best, userLat: location.lat, userLng: location.lng),
lat: best.lat,
lng: best.lng,
updatedAt: Date()
+111 -13
View File
@@ -85,7 +85,7 @@ struct OnboardingView: View {
private var welcomePage: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
AnimatedOnboardingIcon(tint: .accentColor) {
// The app icon itself, with the standard iOS rounded-corner
// treatment (~22% of the size), so onboarding matches the
// Home Screen.
@@ -122,8 +122,7 @@ struct OnboardingView: View {
private var locationPage: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
Circle().fill(Color.blue.opacity(0.12)).frame(width: 96, height: 96)
AnimatedOnboardingIcon(tint: .blue, motion: .locationArrow) {
Image(systemName: "location.fill")
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(.blue)
@@ -154,8 +153,7 @@ struct OnboardingView: View {
private var notificationsPage: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
Circle().fill(Color.orange.opacity(0.12)).frame(width: 96, height: 96)
AnimatedOnboardingIcon(tint: .orange, motion: .alarmBell) {
Image(systemName: "bell.badge.fill")
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(.orange)
@@ -186,8 +184,7 @@ struct OnboardingView: View {
private var dataPage: some View {
VStack(spacing: 0) {
Spacer()
ZStack {
Circle().fill(Color.teal.opacity(0.12)).frame(width: 96, height: 96)
AnimatedOnboardingIcon(tint: .teal, motion: .downloadArrow) {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 40, weight: .semibold))
.foregroundStyle(.teal)
@@ -248,19 +245,27 @@ struct OnboardingView: View {
) {
if prompter.locationDenied {
openSettings()
} else if prompter.locationGranted {
page = 2 // already decided — just move on
} else {
// The prompt fires on LEAVING this page (onChange(of:
// page), forward advance only) — the description is on
// screen until the user moves on.
page = 2
// Fire the system prompt NOW, while this page is still on
// screen (after it's been read) and BEFORE advancing — so
// the prompt never covers the next page's animation. Auto-
// advance on grant moves us on once the user responds;
// denying leaves the page's "Open Settings" path.
prompter.requestLocation()
}
}
case 2:
primaryButton(
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
) {
// Prompt fires on leaving this page (same rule as Location).
page = 3
if prompter.notificationsDenied || prompter.notificationsGranted {
page = 3 // decided either way — move on
} else {
// Same rule as Location: prompt now, before advancing.
prompter.requestNotifications()
}
}
case 3:
// No permission on this page — prices need none. Straight on.
@@ -326,6 +331,99 @@ struct OnboardingView: View {
}
}
private struct AnimatedOnboardingIcon<Content: View>: View {
enum Motion {
case gentle
case locationArrow
case alarmBell
case downloadArrow
}
let tint: Color
var motion: Motion = .gentle
@ViewBuilder var content: Content
@State private var settled = false
@State private var iconRotation: Double = 0
@State private var iconOffsetY: CGFloat = 0
@State private var iconScale: CGFloat = 0.92
var body: some View {
ZStack {
Circle()
.fill(tint.opacity(0.10))
.frame(width: 108, height: 108)
.scaleEffect(settled ? 1.0 : 0.94)
Circle()
.stroke(tint.opacity(0.18), lineWidth: 1)
.frame(width: 118, height: 118)
.scaleEffect(settled ? 1.03 : 0.97)
.opacity(settled ? 1.0 : 0.55)
content
.scaleEffect(iconScale)
.rotationEffect(.degrees(iconRotation))
.offset(y: iconOffsetY)
}
.shadow(color: tint.opacity(0.12), radius: settled ? 12 : 4, y: settled ? 6 : 2)
.animation(.spring(response: 0.55, dampingFraction: 0.82), value: settled)
.onAppear {
playAnimation()
}
}
private func playAnimation() {
settled = false
iconRotation = 0
iconOffsetY = 4
iconScale = 0.92
switch motion {
case .gentle:
withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) {
settled = true
iconOffsetY = 0
iconScale = 1.0
}
case .locationArrow:
iconRotation = -120
withAnimation(.linear(duration: 0.45)) {
settled = true
iconRotation = 24
iconOffsetY = 0
iconScale = 1.02
}
withAnimation(.spring(response: 0.45, dampingFraction: 0.62).delay(0.45)) {
iconRotation = 0
iconScale = 1.0
}
case .alarmBell:
settled = true
iconOffsetY = 0
iconScale = 1.0
withAnimation(.linear(duration: 0.10)) { iconRotation = -14 }
withAnimation(.linear(duration: 0.10).delay(0.10)) { iconRotation = 14 }
withAnimation(.linear(duration: 0.10).delay(0.20)) { iconRotation = -10 }
withAnimation(.linear(duration: 0.10).delay(0.30)) { iconRotation = 10 }
withAnimation(.spring(response: 0.34, dampingFraction: 0.72).delay(0.40)) { iconRotation = 0 }
case .downloadArrow:
iconOffsetY = -22
withAnimation(.easeIn(duration: 0.22)) {
settled = true
iconScale = 1.0
iconOffsetY = 8
}
withAnimation(.spring(response: 0.42, dampingFraction: 0.62).delay(0.22)) {
iconOffsetY = 0
}
}
}
}
/// Owns the system permission requests during onboarding and publishes
/// their outcomes so the pages can reflect them live.
@MainActor
+29 -5
View File
@@ -84,6 +84,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
@Published var lastAlert: String?
@Published private(set) var lastTestResult: String?
@Published var pendingStationMap: StationMapRequest?
/// Station tapped via notification when Settings → Tap station = More info.
/// ContentView presents this as a StationDetailView sheet.
@Published var pendingDetailStation: FuelStation?
/// Debug-only snapshot for the Settings → Debug section. Recomputed on
/// every location/stations change via `refreshDebugStatus()`.
@Published private(set) var debugStatus: DebugLocationStatus?
@@ -791,9 +794,11 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
}
}
/// Tapping a notification opens Apple Maps directions to the station from
/// the user's current location. If Apple Maps can't be opened (e.g. inside
/// LiveContainer), an in-app map sheet with directions is shown instead.
/// Tapping a notification honours Settings → Tap station: Open map opens
/// Apple Maps directions to the station from the user's current location;
/// More info opens the station's detail sheet in the app instead. If Apple
/// Maps can't be opened (e.g. inside LiveContainer), an in-app map sheet
/// with directions is shown instead.
///
/// When the notification carried tie-choice action buttons, the tapped
/// button's identifier ("tie_0", "tie_1", …) selects the corresponding
@@ -818,9 +823,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
let tieName = ties[index]["name"] as? String,
let tieLat = ties[index]["lat"] as? Double,
let tieLng = ties[index]["lng"] as? Double {
openDirections(to: tieName, latitude: tieLat, longitude: tieLng)
routeNotificationTap(name: tieName, latitude: tieLat, longitude: tieLng)
} else if !isTie, let name, let lat, let lng {
openDirections(to: name, latitude: lat, longitude: lng)
routeNotificationTap(name: name, latitude: lat, longitude: lng)
}
// Tie body tap: intentionally no directions — the user must pick
// an option button to choose a station.
@@ -828,6 +833,25 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
}
}
/// Routes a resolved notification tap through the user's Tap station
/// preference: Open map → Apple Maps; More info → in-app detail sheet
/// (falling back to the map sheet when the station isn't in cache).
@MainActor
private func routeNotificationTap(name: String, latitude: Double, longitude: Double) {
guard FuelStore.loadStationTapAction() == .showDetails else {
openDirections(to: name, latitude: latitude, longitude: longitude)
return
}
if let station = stations.first(where: { $0.lat == latitude && $0.lng == longitude })
?? stations.first(where: { $0.name == name }) {
pendingDetailStation = station
} else {
// Station not in cache (e.g. data refreshed since the alert fired)
// — fall back to the map sheet so the tap still shows something.
pendingStationMap = StationMapRequest(name: name, latitude: latitude, longitude: longitude)
}
}
/// Opens Apple Maps with driving directions to the station; falls back to
/// the in-app map sheet when the hand-off fails.
@MainActor
+98
View File
@@ -0,0 +1,98 @@
// RoadDistanceService.swift — computes Apple-Maps-matched ROAD distances for
// nearby stations and caches them (keychain) so widgets + Live Activity can
// show real driving distance instead of straight-line haversine.
//
// Runs only in the APP: MKDirections is network-bound and the widget extension
// has a tiny execution budget + a ~40-70/day refresh budget, so routing belongs
// here, not in the widget. The widget/Live Activity just read the cache.
//
// Throttling: recompute at most every `throttleMinutes`, or when the user has
// moved `moveThresholdMeters` from where the cache was built. Bounded to the
// `candidatesPerPass` nearest stations so a pass stays a handful of route calls.
import Foundation
import MapKit
import WidgetKit
enum RoadDistanceService {
/// Upper bound on stations routed per pass, so a pass stays a bounded set of
/// route calls. Raised from 12 so stations past the old nearest-12 cutoff
/// still get real road distances instead of a straight-line fallback.
static let candidatesPerPass = 40
/// Only route stations within this straight-line radius (km). Covers the
/// largest search radius the UI exposes (15 mi ≈ 24.1 km) plus margin, so
/// every station a widget/Live Activity/list can actually show gets routed.
static let maxRadiusKM: Double = 25
/// Don't route again more often than this (minutes).
static let throttleMinutes: Double = 10
/// Recompute when the user moves more than this (metres) from the last
/// source location.
static let moveThresholdMeters: Double = 400
/// Refreshes the cached road distances for the in-radius stations around
/// `lat`/`lng`. Throttled by time + distance; safe to call on every fix.
static func refreshIfNeeded(stations: [FuelStation], lat: Double, lng: Double) async {
guard !stations.isEmpty else { return }
// Throttle: keep cached values when fresh and the user hasn't moved far.
if let cache = FuelStore.loadRoadDistances() {
let elapsed = Date().timeIntervalSince1970 - cache.updatedAt
let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng)
if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters {
return
}
}
// Candidate stations: the nearest-by-straight-line subset that the UI
// could actually display, capped so a pass stays bounded.
let nearest = stations
.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
.prefix(candidatesPerPass)
.filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM }
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
var entries: [String: CachedRoadDistance] = [:]
for station in nearest {
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
if let meters = await roadMeters(from: origin, to: dest) {
// Store the exact pin that was routed so the display layer can
// refuse to serve this value if the station later appears with
// a different coordinate (corrected pin / other data source).
entries[station.id] = CachedRoadDistance(meters: meters,
lat: station.lat,
lng: station.lng)
}
}
guard !entries.isEmpty else { return }
FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries)
// Wake the widgets so the new road distances surface immediately.
WidgetCenter.shared.reloadAllTimelines()
}
/// Driving distance (metres) between two coordinates via Apple Maps routing.
private static func roadMeters(from: CLLocationCoordinate2D, to: CLLocationCoordinate2D) async -> Double? {
let request = MKDirections.Request()
request.source = MKMapItem(placemark: MKPlacemark(coordinate: from))
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: to))
request.transportType = .automobile
request.requestsAlternateRoutes = false
do {
let response = try await MKDirections(request: request).calculate()
return response.routes.first?.distance
} catch {
return nil
}
}
/// Straight-line haversine distance between two coordinates, in metres.
private static func haversineMeters(_ lat1: Double, _ lng1: Double, _ lat2: Double, _ lng2: Double) -> Double {
let r = 6371000.0
let dLat = (lat2 - lat1) * .pi / 180
let dLng = (lng2 - lng1) * .pi / 180
let a = sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * .pi / 180) * cos(lat2 * .pi / 180) *
sin(dLng / 2) * sin(dLng / 2)
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
}
}
+110 -19
View File
@@ -13,8 +13,13 @@ import WidgetKit
/// freshness) is debug-only too — moved here so release builds don't expose
/// relay plumbing.
struct SettingsView: View {
/// Shared tip-purchase store — owned by ContentView so its outcome can
/// ride the app-wide status banner (above the tabs), not a local card.
@ObservedObject var tipStore: TipStore
@Binding var distanceUnit: DistanceUnit
@Binding var priceDisplayStyle: PriceDisplayStyle
@Binding var stationTapAction: StationTapAction
@Binding var dataRefreshMode: DataRefreshMode
/// The fuel + radius currently configured for alerts (mirrors the Alerts
/// tab) so the test notification matches what real alerts will say.
var alertsFuel: FuelType = .e10
@@ -53,9 +58,6 @@ struct SettingsView: View {
/// Clears the armed fence (monitor.clearDebugFence).
var onClearDebugFence: () -> Void = {}
@StateObject private var tipStore = TipStore()
@State private var showTipAlert = false
@State private var tipAlertMessage = ""
@State private var testAlertMessage: String?
/// Hidden developer flag — the Debug section only appears when on. Toggled
/// by tapping the About → Version row five times (NOT a user-facing switch).
@@ -103,6 +105,7 @@ struct SettingsView: View {
.onChange(of: distanceUnit) { _, newValue in
FuelStore.saveDistanceUnit(newValue)
WidgetCenter.shared.reloadAllTimelines()
WatchSyncManager.shared.pushSnapshot()
}
Picker("Price display", selection: $priceDisplayStyle) {
ForEach(PriceDisplayStyle.allCases) { style in
@@ -113,6 +116,7 @@ struct SettingsView: View {
.onChange(of: priceDisplayStyle) { _, newValue in
FuelStore.savePriceDisplayStyle(newValue)
WidgetCenter.shared.reloadAllTimelines()
WatchSyncManager.shared.pushSnapshot()
}
} header: {
Text("Units")
@@ -120,6 +124,46 @@ struct SettingsView: View {
Text("Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).")
}
Section {
Picker("Tap station", selection: $stationTapAction) {
ForEach(StationTapAction.allCases) { action in
Text(action.displayName).tag(action)
}
}
.pickerStyle(.segmented)
.onChange(of: stationTapAction) { _, newValue in
FuelStore.saveStationTapAction(newValue)
}
} header: {
Text("Tap action")
} footer: {
Text("Tapping a station either opens directions straight away or shows its details first.")
}
Section {
Picker("Data checking", selection: $dataRefreshMode) {
ForEach(DataRefreshMode.allCases) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.segmented)
.onChange(of: dataRefreshMode) { _, newValue in
FuelStore.saveDataRefreshMode(newValue)
SmartDataRefreshScheduler.scheduleNextIfNeeded()
WatchSyncManager.shared.pushSnapshot()
}
VStack(alignment: .leading, spacing: 8) {
Text(dataRefreshMode.summary)
Text("Background checks are best-effort and happen only when iOS allows, so timing is not exact.")
.foregroundStyle(.secondary)
}
.font(.footnote)
} header: {
Text("Data checking")
} footer: {
Text("Standard saves battery and refreshes price data up to twice daily. Smart checks for newer data more often in the background and only refreshes full prices when an update is available.")
}
Section {
Button {
onShowOnboarding()
@@ -359,13 +403,6 @@ struct SettingsView: View {
Text("About")
}
if let message = tipStore.message {
Section {
Text(message)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Settings")
.onAppear {
@@ -610,9 +647,19 @@ final class TipStore: ObservableObject {
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A
]
/// A banner outcome: the exact copy to show plus its icon + tint.
struct TipOutcome: Hashable {
let message: String
let icon: String
let tint: Color
}
@Published private(set) var products: [String: Product] = [:]
@Published private(set) var purchaseInProgress = false
@Published private(set) var message: String?
@Published private(set) var outcome: TipOutcome?
/// Auto-dismiss timer for the outcome banner (replaced on each new outcome).
private var dismissTask: Task<Void, Never>?
/// Listens for transactions that complete OUTSIDE the direct purchase()
/// call — Ask to Buy approvals, payments finished on another device
@@ -630,6 +677,7 @@ final class TipStore: ObservableObject {
deinit {
updatesTask?.cancel()
dismissTask?.cancel()
}
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
@@ -638,7 +686,30 @@ final class TipStore: ObservableObject {
// Only acknowledge our own consumables (future products get their own).
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
await transaction.finish()
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "")
showOutcome(
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
icon: "checkmark.circle.fill",
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
)
}
/// Sets the banner outcome and schedules its auto-dismiss (~3.5 s).
/// Re-triggering cancels the previous timer so rapid taps don't collide.
private func showOutcome(_ message: String, icon: String, tint: Color) {
dismissTask?.cancel()
outcome = TipOutcome(message: message, icon: icon, tint: tint)
dismissTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 3_500_000_000)
guard !Task.isCancelled else { return }
withAnimation(.easeOut(duration: 0.25)) { self?.outcome = nil }
}
}
/// Tap-to-dismiss — clears the banner and cancels the pending auto-dismiss.
func dismissOutcome() {
dismissTask?.cancel()
dismissTask = nil
withAnimation(.easeOut(duration: 0.2)) { outcome = nil }
}
func displayPrice(for tier: TipTier) -> String {
@@ -667,7 +738,11 @@ final class TipStore: ObservableObject {
// Connect yet), still allow the attempt so the user sees a clear
// outcome rather than a dead button.
guard let product = products[tier.id] else {
message = NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: "")
showOutcome(
NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: ""),
icon: "info.circle.fill",
tint: Color(.secondaryLabel)
)
return
}
@@ -680,19 +755,35 @@ final class TipStore: ObservableObject {
// Consume the consumable — otherwise StoreKit re-delivers
// it through Transaction.updates on every launch.
await transaction.finish()
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "")
showOutcome(
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
icon: "checkmark.circle.fill",
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
)
case .unverified:
message = NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: "")
showOutcome(
NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""),
icon: "exclamationmark.triangle.fill",
tint: .orange
)
}
case .userCancelled:
message = nil // silent — the user just closed the sheet
dismissOutcome() // silent — the user just closed the sheet
case .pending:
message = NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: "")
showOutcome(
NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: ""),
icon: "hourglass",
tint: Color(red: 0.39, green: 0.82, blue: 1.0) // #64D2FF
)
@unknown default:
message = nil
dismissOutcome()
}
} catch {
message = String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription)
showOutcome(
String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription),
icon: "xmark.circle.fill",
tint: .red
)
}
}
}
+15 -16
View File
@@ -125,7 +125,7 @@ struct CheapestFuelIntent: AppIntent {
struct DirectionsToCheapestFuelIntent: AppIntent {
static var title: LocalizedStringResource = "Directions to Cheapest Fuel Near Me"
static var description = IntentDescription(
"Opens Apple Maps directions to the cheapest station selling a fuel near you, using the latest cached prices."
"Opens directions to the cheapest station selling a fuel near you, using the latest cached prices."
)
@Parameter(title: "Fuel")
@@ -285,7 +285,7 @@ struct FavouriteFuelPriceIntent: AppIntent {
struct DirectionsToFavouriteFuelIntent: AppIntent {
static var title: LocalizedStringResource = "Directions to Favourite Fuel Station"
static var description = IntentDescription(
"Opens Apple Maps directions to your top favourite station for a fuel."
"Opens directions to your top favourite station for a fuel."
)
@Parameter(title: "Fuel")
@@ -404,7 +404,7 @@ struct FuelMessage: View {
/// Shortcut slot and adds no phrase.
struct OpenDirectionsIntent: AppIntent {
static var title: LocalizedStringResource = "Directions"
static var description = IntentDescription("Opens Apple Maps directions to the station.")
static var description = IntentDescription("Opens directions to the station.")
static var isDiscoverable: Bool = false
@Parameter var stationName: String
@@ -489,19 +489,14 @@ struct FuelMessageSnippetIntent: SnippetIntent {
struct FuelBoardShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
// Generic parameterized shortcut — matches whatever fuel word Siri
// resolves. Parameter resolution is flaky on-device ("cheapest diesel"
// matched, "cheapest unleaded" didn't), which is why the fixed-fuel
// entries below carry the fuel word as LITERAL phrase text.
AppShortcut(
intent: CheapestFuelIntent(),
phrases: [
"Ask \(.applicationName) what's the cheapest \(\.$fuel) near me",
],
shortTitle: "Cheapest Fuel",
systemImageName: "fuelpump"
)
// NOTE (2026-08-20): the generic parameterized shortcut ("cheapest
// ${fuel}") was REMOVED. It competed with the fixed-fuel literal-word
// shortcuts for the same intent (parameter resolution was already flaky
// on-device), which degraded Siri's NLU ranking and caused the
// hit-and-miss "can't do that, searching in app" fallback. Now Siri
// routes on literal fuel words only, and the freed slot keeps us under
// the 10-shortcut cap. Every phrase carries .applicationName (iOS 26
// metadata-processor requirement) and mirrors how people actually ask.
AppShortcut(
intent: CheapestFuelIntent(fuel: .e10),
phrases: [
@@ -509,6 +504,10 @@ struct FuelBoardShortcuts: AppShortcutsProvider {
"Ask \(.applicationName) for the cheapest petrol near me",
"Find the cheapest petrol near me \(.applicationName)",
"Find the cheapest unleaded near me \(.applicationName)",
"What's the cheapest unleaded near me \(.applicationName)",
"What's the cheapest petrol near me \(.applicationName)",
"Cheapest unleaded near me \(.applicationName)",
"Where's the cheapest petrol \(.applicationName)",
],
shortTitle: "Cheapest Unleaded",
systemImageName: "fuelpump"
+89
View File
@@ -0,0 +1,89 @@
import BackgroundTasks
import Foundation
import WidgetKit
enum SmartDataRefreshScheduler {
static let taskIdentifier = "com.apt.fuelboard.smart-refresh"
static func register() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in
guard let task = task as? BGAppRefreshTask else {
task.setTaskCompleted(success: false)
return
}
handle(task)
}
}
static func scheduleNextIfNeeded() {
BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: taskIdentifier)
guard FuelStore.loadDataRefreshMode() == .smart else { return }
let request = BGAppRefreshTaskRequest(identifier: taskIdentifier)
request.earliestBeginDate = Date(timeIntervalSinceNow: FuelStore.smartProbeInterval)
do {
try BGTaskScheduler.shared.submit(request)
} catch {
#if DEBUG
print("SMART-REFRESH schedule failed: \(error)")
#endif
}
}
private static func handle(_ task: BGAppRefreshTask) {
scheduleNextIfNeeded()
let refreshTask = Task {
let success = await SmartDataRefreshCoordinator.runBackgroundProbe()
task.setTaskCompleted(success: success)
}
task.expirationHandler = {
refreshTask.cancel()
}
}
}
enum SmartDataRefreshCoordinator {
@discardableResult
static func runBackgroundProbe(now: Date = Date()) async -> Bool {
guard FuelStore.loadDataRefreshMode() == .smart else { return true }
guard FuelStore.isSmartProbeDue(now: now) || FuelStore.loadStations().isEmpty else {
return true
}
FuelStore.saveLastSmartProbe(now)
guard !Task.isCancelled else { return false }
guard let latest = await FuelHistoryStore.fetchLatest() else {
return false
}
let latestDay = latest.availableTo ?? latest.date
let latestUpdated = latest.dataUpdated
let cachedDay = MirrorFuelProvider.loadDumpCache()?.day
let savedUpdated = FuelStore.loadDataUpdated()
let shouldFetchFullDump = FuelStore.loadStations().isEmpty
|| !FuelStore.isCacheFresh(now: now)
|| (latestDay != nil && latestDay != cachedDay)
|| (latestUpdated != nil && latestUpdated != savedUpdated)
guard shouldFetchFullDump, !Task.isCancelled else { return true }
do {
let fetched = try await FuelPriceProvider.active.fetchStations(
near: nil,
lng: nil,
fuel: FuelStore.loadSelectedFuel(),
radiusKM: nil
)
guard !Task.isCancelled else { return false }
FuelStore.saveStations(fetched)
FuelStore.saveLastRefresh(now)
await WatchSyncManager.shared.pushSnapshot()
if let meta = LiveChainProvider.latestMeta {
FuelStore.saveRelayMeta(meta)
}
WidgetCenter.shared.reloadAllTimelines()
return true
} catch {
return false
}
}
}
+81
View File
@@ -0,0 +1,81 @@
import SwiftUI
import MapKit
import UIKit
/// Detail sheet for a tapped station (when Settings → Tap station = More info).
/// Shows a small map preview, the station name, address, distance from the
/// current location, and a Directions button that opens Apple Maps — the same
/// destination a direct tap would have opened.
struct StationDetailView: View {
let station: FuelStation
let location: Coordinate?
let distanceUnit: DistanceUnit
@Environment(\.dismiss) private var dismiss
@State private var showingReportIssueConfirmation = false
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
private var stationCoordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
}
var body: some View {
NavigationStack {
List {
Section {
Map(position: .constant(.region(MKCoordinateRegion(
center: stationCoordinate,
span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
)))) {
Marker(station.name, coordinate: stationCoordinate)
.tint(.red)
}
.frame(height: 180)
.clipShape(RoundedRectangle(cornerRadius: 12))
.allowsHitTesting(false)
.listRowInsets(EdgeInsets())
}
Section {
LabeledContent("Name", value: station.name)
LabeledContent("Address", value: "\(station.address), \(station.postcode)")
if let location {
let km = FuelStore.displayDistanceKM(
station: station, userLat: location.lat, userLng: location.lng
)
LabeledContent("Distance", value: distanceUnit.format(km))
.monospacedDigit()
}
}
Section {
Button {
if let url = station.mapsDirectionsURL {
UIApplication.shared.open(url)
}
} label: {
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
}
Button(role: .destructive) {
showingReportIssueConfirmation = true
} label: {
Label("Report issue", systemImage: "exclamationmark.bubble.fill")
}
}
}
.navigationTitle(station.name)
.navigationBarTitleDisplayMode(.inline)
.alert("Report an error?", isPresented: $showingReportIssueConfirmation) {
Button("Cancel", role: .cancel) { }
Button("Report") {
UIApplication.shared.open(reportIssueURL)
}
} message: {
Text("Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.")
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
}
}
}
}
+41 -15
View File
@@ -15,6 +15,7 @@ struct StationsView: View {
let topStationID: String?
let location: Coordinate?
let favouriteIDs: Set<String>
let stationTapAction: StationTapAction
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
var onRefresh: () async -> Void = {}
@@ -29,6 +30,9 @@ struct StationsView: View {
/// info button in the header — keeps the list focused on stations.
@State private var showKey = false
/// Station picked for the More-info sheet (tap action = showDetails).
@State private var detailStation: FuelStation?
/// Bottom-of-tab explainer (moved from the top 2026-08-16): mode/radius/
/// directions context + "N/TOTAL stations updated". The numerator is the
/// current list pool; the denominator is the full UK station total from
@@ -38,6 +42,9 @@ struct StationsView: View {
let unit = distanceUnit.label(for: Double(miles))
let fuel = selectedFuel.displayName
let mode = sortMode == .closest ? "Closest" : "Cheapest"
let tapHint = stationTapAction == .openMap
? "tap a station for directions."
: "tap a station for details."
let ratio: String
if let total = FuelStore.loadStationCount() {
ratio = "\(totalCount)/\(total)"
@@ -48,9 +55,9 @@ struct StationsView: View {
if sortMode == .closest {
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
}
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · tap a station for directions."
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · \(tapHint)"
}
return "\(mode) \(fuel) — \(ratio) stations updated · tap a station for directions."
return "\(mode) \(fuel) — \(ratio) stations updated · \(tapHint)"
}
var body: some View {
@@ -126,7 +133,9 @@ struct StationsView: View {
baselinePrice: baselinePrice,
isTopResult: station.id == topStationID,
isFavourite: favouriteIDs.contains(station.id),
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
tapAction: stationTapAction,
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
onShowDetails: { detailStation = $0 }
)
}
@@ -260,6 +269,13 @@ struct StationsView: View {
.presentationDetents([.fraction(0.6)])
.presentationBackground(Color(UIColor.systemGroupedBackground))
}
.sheet(item: $detailStation) { station in
StationDetailView(
station: station,
location: location,
distanceUnit: distanceUnit
)
}
}
}
@@ -317,17 +333,6 @@ extension FuelType {
case .diesel: return "Diesel"
}
}
/// Fuel colour wheel (user-chosen palette): green = unleaded (#30D158),
/// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Used for the
/// fuel-type tab icons and the title icon.
var tintColor: Color {
switch self {
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
}
}
}
/// Fuel-type selector styled like a segmented control, with a coloured pump
@@ -342,20 +347,26 @@ struct FuelTypeSegmentedPicker: View {
var fuels: [FuelType] = FuelType.allCases
var onSelect: (FuelType) -> Void = { _ in }
@Namespace private var selectionSlider
var body: some View {
HStack(spacing: 3) {
ForEach(fuels) { fuel in
let isSelected = fuel == selection
Button {
withAnimation(.spring(response: 0.42, dampingFraction: 0.78)) {
selection = fuel
}
onSelect(fuel)
} label: {
HStack(spacing: 5) {
Image(systemName: "fuelpump.fill")
.font(.caption2)
.foregroundStyle(fuel.tintColor)
.scaleEffect(isSelected ? 1.08 : 0.94)
Text(fuel.shortName)
.font(.subheadline.weight(isSelected ? .semibold : .regular))
.contentTransition(.opacity)
}
.foregroundStyle(isSelected ? Color.primary : Color.secondary)
.frame(maxWidth: .infinity)
@@ -364,14 +375,29 @@ struct FuelTypeSegmentedPicker: View {
if isSelected {
Capsule()
.fill(Color(UIColor.systemBackground))
.shadow(color: .black.opacity(0.08), radius: 1, y: 0.5)
.overlay {
Capsule()
.stroke(fuel.tintColor.opacity(0.28), lineWidth: 1)
}
.shadow(color: fuel.tintColor.opacity(0.16), radius: 6, y: 2)
.matchedGeometryEffect(id: "fuelSelectionSlider", in: selectionSlider)
}
}
.scaleEffect(isSelected ? 1.02 : 0.98)
.contentShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(3)
.background(Capsule().fill(Color(.secondarySystemBackground)))
.animation(.spring(response: 0.42, dampingFraction: 0.78), value: selection)
}
}
#Preview("Fuel Picker") {
@Previewable @State var selectedFuel: FuelType = .e10
FuelTypeSegmentedPicker(selection: $selectedFuel)
.padding()
.background(Color(.systemGroupedBackground))
}
+48 -2
View File
@@ -25,6 +25,7 @@ struct TrendsView: View {
var onHistoryRecovered: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var fuel: FuelType = .e10
@State private var rangeDays: Int = 30
@@ -34,6 +35,13 @@ struct TrendsView: View {
@State private var loadFailed = false
@State private var firstSnapshot: String?
/// Per-series draw-in waterline: maps stationID → how many leading points
/// are revealed, so each line traces left→right on first appearance.
/// Series reveal in a short staggered cascade so the draw is clearly
/// visible even with only 2 points per line. Stays full after the first
/// reveal so range/mode switches morph instead of re-tracing.
@State private var revealed: [String: Int] = [:]
/// Seeded from `selectedFuel` (the fuel the tab was on) so the sheet
/// opens where the user was — same pattern as FavouritesView.
init(favourites: [FavouriteEntry],
@@ -157,7 +165,13 @@ struct TrendsView: View {
// pointer probe already done above.
loadFailed = firstSnapshot == nil
}
// Always redraw the lines on a selection change. `.task(id:)` fires on
// the initial appear and on every fuel/range switch (there's no
// periodic refetch in this sheet), so a staggered per-series draw-in
// replays exactly when the user picks 7/30/90 (or switches fuel) while
// a width-stable morph keeps the x-axis/y-range from jumping abruptly.
series = fetched
revealSeries(fetched)
// A failure with no data IS a connection problem — raise the global
// banner so the user isn't stuck with a silent retry state. Success
// clears it (only if the banner is the connection banner).
@@ -168,6 +182,32 @@ struct TrendsView: View {
}
}
/// Replays the staggered per-series draw-in for the given histories:
/// resets the waterline, then cascades each station's line left→right,
/// 0.18s apart, on every range/fuel selection. Reduce Motion jump-cuts
/// straight to the full state.
private func revealSeries(_ histories: [StationHistory]) {
let hasPoints = histories.contains { !$0.points.isEmpty }
guard hasPoints else { return }
if reduceMotion {
revealed = histories.reduce(into: [:]) { $0[$1.stationID] = $1.points.count }
return
}
revealed = [:]
let cascadeNS = UInt64(0.18 * 1_000_000_000)
for (i, h) in histories.enumerated() {
let sid = h.stationID
let total = h.points.count
Task {
try? await Task.sleep(nanoseconds: UInt64(i) * cascadeNS)
guard !Task.isCancelled else { return }
withAnimation(.easeOut(duration: 0.5)) {
revealed[sid] = total
}
}
}
}
private func yLabel(_ pence: Double) -> String {
switch mode {
case .price:
@@ -330,6 +370,9 @@ struct TrendsView: View {
.font(.system(size: 30, weight: .bold, design: .default))
.monospacedDigit()
.foregroundStyle(.primary)
// Roll the digits to the new figure on range/mode change
// (fires inside the animated transaction above).
.contentTransition(.numericText(value: headAvg))
if let delta = headlineDelta, delta != 0 {
Label(
"\(deltaIsGood ? "−" : "+")\(abs(delta), specifier: "%.1f")p",
@@ -389,13 +432,13 @@ struct TrendsView: View {
// type-checker's budget.)
Chart {
ForEach(displaySeries) { history in
ForEach(history.points) { point in
ForEach(history.points.prefix(revealed[history.stationID] ?? 0)) { point in
areaMark(point, series: history.name,
color: seriesColor(index(of: history.stationID)))
}
}
ForEach(displaySeries) { history in
ForEach(history.points) { point in
ForEach(history.points.prefix(revealed[history.stationID] ?? 0)) { point in
lineMark(point, series: history.name,
color: seriesColor(index(of: history.stationID)))
}
@@ -424,6 +467,9 @@ struct TrendsView: View {
}
}
.frame(height: 190)
// Price ↔ vs-cheapest is a pure view toggle (no network): animate the
// lines + axis gliding to the rebased series.
.animation(.easeInOut(duration: 0.35), value: mode)
}
/// One gradient-filled area band under a single point of a series.
+118
View File
@@ -0,0 +1,118 @@
import Foundation
import WatchConnectivity
extension Notification.Name {
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
static let fuelBoardShowStationDetail = Notification.Name("FuelBoardShowStationDetail")
}
@MainActor
final class WatchSyncManager: NSObject, WCSessionDelegate {
static let shared = WatchSyncManager()
private var pendingSnapshotPush = false
private override init() {
super.init()
}
func activate() {
guard WCSession.isSupported() else { return }
pendingSnapshotPush = true
let session = WCSession.default
if session.delegate !== self {
session.delegate = self
}
session.activate()
}
func pushSnapshot() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
guard session.activationState == .activated else {
pendingSnapshotPush = true
return
}
do {
try session.updateApplicationContext(snapshotContext())
pendingSnapshotPush = false
} catch {
print("WATCH-SYNC push failed: \(error.localizedDescription)")
}
}
private func snapshotContext() -> [String: Any] {
var context: [String: Any] = [:]
if let favourites = try? JSONEncoder().encode(FuelStore.loadFavourites()) {
context[FuelStore.favouritesKey] = favourites
}
// Do NOT send the full station dump to watchOS via WatchConnectivity.
// The UK dataset is megabytes large and exceeds application-context
// payload limits, which prevents any snapshot from arriving. The
// favourites payload already carries station snapshots + prices, so the
// watch can render favourites without the full list.
context[FuelStore.fuelKey] = FuelStore.loadSelectedFuel().rawValue
context[FuelStore.distanceUnitKey] = FuelStore.loadDistanceUnit().rawValue
context[FuelStore.priceDisplayStyleKey] = FuelStore.loadPriceDisplayStyle().rawValue
if let lastRefresh = FuelStore.loadLastRefresh() {
context[FuelStore.lastRefreshKey] = String(lastRefresh.timeIntervalSince1970)
}
if let handled = FuelStore.loadWatchRefreshHandled() {
context[FuelStore.watchRefreshHandledKey] = String(handled.timeIntervalSince1970)
}
if let location = FuelStore.loadLocationWithDate() {
context[FuelStore.locationKey] = "\(location.coordinate.lat),\(location.coordinate.lng),\(location.date.timeIntervalSince1970)"
}
return context
}
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
if let error {
print("WATCH-SYNC activation failed: \(error.localizedDescription)")
return
}
Task { @MainActor in
if activationState == .activated, self.pendingSnapshotPush {
self.pushSnapshot()
}
}
}
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
nonisolated func sessionDidDeactivate(_ session: WCSession) {
Task { @MainActor in
WCSession.default.activate()
}
}
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
handleIncomingMessage(userInfo)
}
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
handleIncomingMessage(message)
}
private nonisolated func handleIncomingMessage(_ payload: [String: Any]) {
if payload["fuelboard.snapshotRequest"] as? String != nil {
Task { @MainActor in
self.pushSnapshot()
}
}
guard let raw = payload[FuelStore.watchRefreshRequestKey] as? String,
let timestamp = TimeInterval(raw) else { return }
let date = Date(timeIntervalSince1970: timestamp)
Task { @MainActor in
FuelStore.requestWatchRefresh(date)
NotificationCenter.default.post(name: .fuelBoardWatchRefreshRequested, object: nil)
}
}
}
+13
View File
@@ -56,6 +56,14 @@
"Units" = "Units";
"Price display" = "Price display";
"Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L)." = "Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).";
"Tap station" = "Tap station";
"Tap action" = "Tap action";
"Tapping a station either opens directions straight away or shows its details first." = "Tapping a station either opens directions straight away or shows its details first.";
"Open map" = "Open map";
"More info" = "More info";
"Name" = "Name";
"Address" = "Address";
"Distance" = "Distance";
"Show introduction" = "Replay onboarding";
"Replay the welcome screen, including the location and notification permission prompts." = "Replay the welcome screen, including the location and notification permission prompts.";
"Test alert notification (real data)" = "Test alert notification (real data)";
@@ -139,6 +147,11 @@
"You" = "You";
"Done" = "Done";
"Directions" = "Directions";
"Report issue" = "Report issue";
"Report an error?" = "Report an error?";
"Cancel" = "Cancel";
"Report" = "Report";
"Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser." = "Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.";
/* Station row */
"best" = "best";
@@ -0,0 +1 @@
../../../Shared/DataRefreshMode.swift
@@ -620,3 +620,82 @@ final class OfflineDataLabelTests: XCTestCase {
XCTAssertNil(FuelStore.offlineDataLabel(from: "not-a-date"))
}
}
// MARK: - Road distance cache
final class RoadDistanceCacheTests: XCTestCase {
override func setUp() {
super.setUp()
// Keychain persists across invocations, so a cache left by an earlier
// test or run would pollute these. Overwrite with an empty, far-away
// cache (source at (0,0)) so every test starts from a clean slate.
FuelStore.saveRoadDistances(sourceLat: 0, sourceLng: 0, entries: [:])
}
private func station(_ id: String, _ lat: Double, _ lng: Double) -> FuelStation {
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
lat: lat, lng: lng, prices: [:], priceUpdated: nil)
}
func testDisplayDistanceFallsBackToStraightLineWhenNoCache() {
// London user, station ~ London -> no cache -> straight-line haversine.
let s = station("a", 51.5074, -0.1278)
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
}
func testRoadDistanceUsedWhenCachedNear() {
let s = station("a", 51.5074, -0.1278)
// Cache a road distance of 3.2 km for this station from the user's fix.
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
XCTAssertEqual(km, 3.2, accuracy: 0.0001)
}
func testRoadDistanceNilWhenOriginFar() {
let s = station("a", 51.5074, -0.1278)
// Cache built in London, but the user is now ~200 km away -> stale.
FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278,
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
let meters = FuelStore.roadDistanceMeters(for: s, userLat: 53.4808, userLng: -2.2426)
XCTAssertNil(meters)
// And display falls back to straight-line.
let km = FuelStore.displayDistanceKM(station: s, userLat: 53.4808, userLng: -2.2426)
XCTAssertEqual(km, s.distanceKM(to: 53.4808, lng2: -2.2426), accuracy: 0.0001)
}
func testRoadDistanceUsedForOtherStationNotFound() {
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
// A station that isn't in the cache falls back to straight-line.
let s = station("z", 51.51, -0.13)
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
}
func testRoadDistanceNotServedWhenStationPinDiffers() {
// Route a road distance to station "a" at pin P1.
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
// The SAME station id appears with a moved pin (corrected coordinate /
// different embedded vs live source): the cached route to P1 must NOT
// be served — it belongs to a different location.
let moved = station("a", 51.5400, -0.1600)
let km = FuelStore.displayDistanceKM(station: moved, userLat: 51.6, userLng: -0.1)
XCTAssertEqual(km, moved.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001,
"road value routed to the old pin leaked onto a different coordinate")
}
}
// MARK: - Install identity
final class InstallIdentityTests: XCTestCase {
func testFreshInstallIsStableAfterFirstCall() {
// The first call seeds the common install id (local == keychain), so
// any subsequent call in the same process must report NOT-fresh. This
// holds regardless of persisted keychain/defaults state from prior runs.
_ = FuelStore.isFreshInstall()
XCTAssertFalse(FuelStore.isFreshInstall())
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,156 @@
{
"images": [
{
"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"
},
{
"filename": "Watch-LongLook-88.png",
"idiom": "watch",
"role": "longLook",
"scale": "2x",
"size": "44x44",
"subtype": "42mm"
},
{
"filename": "Watch-LongLook-100.png",
"idiom": "watch",
"role": "longLook",
"scale": "2x",
"size": "50x50",
"subtype": "44mm"
}
],
"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: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 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

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+609
View File
@@ -0,0 +1,609 @@
import SwiftUI
import Foundation
import MapKit
private enum WatchFuelType: String, CaseIterable, Identifiable, Codable {
case e10
case e5
case diesel
var id: String { rawValue }
var displayName: String {
switch self {
case .e10: return "Unleaded"
case .e5: return "Premium"
case .diesel: return "Diesel"
}
}
var shortName: String {
switch self {
case .e10: return "E10"
case .e5: return "E5"
case .diesel: return "B7"
}
}
var color: Color {
switch self {
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
}
}
var symbol: String { "fuelpump.fill" }
var sortRank: Int {
switch self {
case .e10: return 0
case .e5: return 1
case .diesel: return 2
}
}
}
private enum WatchDistanceUnit: String, Codable {
case miles
case kilometers
func format(km: Double) -> String {
switch self {
case .miles:
return String(format: "%.1f mi", km * 0.621371)
case .kilometers:
return String(format: "%.1f km", km)
}
}
}
private enum WatchPriceStyle: String, Codable {
case stationSign
case poundsPence
}
private struct WatchCoordinate {
let lat: Double
let lng: Double
}
private struct WatchStation: Codable, Identifiable {
let id: String
let name: String
let brand: String
let address: String
let postcode: String
let lat: Double
let lng: Double
let prices: [WatchFuelType: Double]
let priceUpdated: TimeInterval?
func distanceKM(to location: WatchCoordinate) -> Double {
let r = 6371.0
let dLat = (location.lat - lat) * .pi / 180
let dLng = (location.lng - lng) * .pi / 180
let a = sin(dLat / 2) * sin(dLat / 2)
+ cos(lat * .pi / 180) * cos(location.lat * .pi / 180)
* sin(dLng / 2) * sin(dLng / 2)
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
}
}
private struct WatchFavouriteEntry: Codable, Identifiable {
let station: WatchStation
let fuel: WatchFuelType
var id: String { "\(fuel.rawValue)|\(station.id)" }
}
private struct WatchFavouriteRow: Identifiable {
let id: String
let name: String
let priceText: String
let distanceText: String?
let isCheapest: Bool
let latitude: Double
let longitude: Double
}
private enum WatchRefreshState {
case idle(lastUpdated: String)
case stale(lastUpdated: String)
case checking
case requested(lastUpdated: String)
case checkedNoNewData(lastUpdated: String)
case needsPhone
case noData
var label: String {
switch self {
case .idle(let lastUpdated): return "Updated \(lastUpdated)"
case .stale(let lastUpdated): return "Cache from \(lastUpdated)"
case .checking: return "Checking…"
case .requested(let lastUpdated): return "Updated · \(lastUpdated)"
case .checkedNoNewData(let lastUpdated): return "No newer data · \(lastUpdated)"
case .needsPhone: return "Refresh on iPhone"
case .noData: return "No cached prices yet"
}
}
var tint: Color {
switch self {
case .idle: return .secondary
case .stale: return .yellow
case .checking: return .yellow
case .requested: return .green
case .checkedNoNewData: return .secondary
case .needsPhone: return .orange
case .noData: return .secondary
}
}
var icon: String {
switch self {
case .idle: return "clock"
case .stale: return "exclamationmark.circle"
case .checking: return "arrow.triangle.2.circlepath"
case .requested: return "checkmark.circle"
case .checkedNoNewData: return "clock.badge.exclamationmark"
case .needsPhone: return "iphone"
case .noData: return "tray"
}
}
}
private enum WatchFuelCache {
static let appGroupSuite = "group.com.apt.fuelboard"
static let favouritesKey = "fuelboard.favourites"
static let stationsKey = "fuelboard.stations"
static let fuelKey = "fuelboard.selectedFuel"
static let distanceUnitKey = "fuelboard.distanceUnit"
static let priceStyleKey = "fuelboard.priceDisplayStyle"
static let locationKey = "fuelboard.lastLocation"
static let lastRefreshKey = "fuelboard.lastRefresh"
static let watchRefreshRequestKey = "fuelboard.watchRefreshRequest"
static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled"
static let refreshInterval: TimeInterval = 12 * 60 * 60
static func defaults() -> UserDefaults? {
UserDefaults(suiteName: appGroupSuite)
}
static func loadSelectedFuel() -> WatchFuelType {
guard let raw = defaults()?.string(forKey: fuelKey), let fuel = WatchFuelType(rawValue: raw) else {
return .e10
}
return fuel
}
static func loadDistanceUnit() -> WatchDistanceUnit {
guard let raw = defaults()?.string(forKey: distanceUnitKey), let unit = WatchDistanceUnit(rawValue: raw) else {
return .miles
}
return unit
}
static func loadPriceStyle() -> WatchPriceStyle {
guard let raw = defaults()?.string(forKey: priceStyleKey), let style = WatchPriceStyle(rawValue: raw) else {
return .stationSign
}
return style
}
static func loadLocation() -> WatchCoordinate? {
guard let raw = defaults()?.string(forKey: locationKey) else { return nil }
let parts = raw.split(separator: ",").compactMap { Double($0) }
guard parts.count >= 2 else { return nil }
return WatchCoordinate(lat: parts[0], lng: parts[1])
}
static func loadLastRefresh() -> Date? {
guard let raw = defaults()?.string(forKey: lastRefreshKey), let ts = TimeInterval(raw) else { return nil }
return Date(timeIntervalSince1970: ts)
}
static func isCacheFresh(lastRefresh: Date?) -> Bool {
guard let lastRefresh else { return false }
return Date().timeIntervalSince(lastRefresh) < refreshInterval
}
static func requestRefresh(_ date: Date = Date()) {
defaults()?.set(String(date.timeIntervalSince1970), forKey: watchRefreshRequestKey)
}
static func loadRefreshRequest() -> Date? {
guard let raw = defaults()?.string(forKey: watchRefreshRequestKey), let ts = TimeInterval(raw) else { return nil }
return Date(timeIntervalSince1970: ts)
}
static func loadRefreshHandled() -> Date? {
guard let raw = defaults()?.string(forKey: watchRefreshHandledKey), let ts = TimeInterval(raw) else { return nil }
return Date(timeIntervalSince1970: ts)
}
static func wasRefreshHandled(since requestDate: Date) -> Bool {
guard let handled = loadRefreshHandled() else { return false }
return handled >= requestDate
}
static func loadStations() -> [WatchStation] {
guard let data = defaults()?.data(forKey: stationsKey),
let stations = try? JSONDecoder().decode([WatchStation].self, from: data) else {
return []
}
return stations
}
static func loadFavourites() -> [WatchFavouriteEntry] {
guard let data = defaults()?.data(forKey: favouritesKey),
let favourites = try? JSONDecoder().decode([WatchFavouriteEntry].self, from: data) else {
return []
}
return favourites
}
static func refreshedFavourites(_ favourites: [WatchFavouriteEntry], from stations: [WatchStation]) -> [WatchFavouriteEntry] {
favourites.map { favourite in
guard let fresh = stations.first(where: { $0.id == favourite.station.id }) else { return favourite }
return WatchFavouriteEntry(station: fresh, fuel: favourite.fuel)
}
}
static func cheapestFavourite(in favourites: [WatchFavouriteEntry], fuel: WatchFuelType) -> WatchFavouriteEntry? {
var best: WatchFavouriteEntry?
for favourite in favourites where favourite.fuel == fuel {
guard let price = favourite.station.prices[fuel] else { continue }
guard let current = best, let currentPrice = current.station.prices[fuel] else {
best = favourite
continue
}
if price < currentPrice {
best = favourite
}
}
return best
}
static func priceText(_ pence: Double, style: WatchPriceStyle) -> String {
switch style {
case .stationSign:
return String(format: "%.1f", pence)
case .poundsPence:
let tenths = Int((pence * 10).rounded())
let whole = tenths / 1000
let major = (tenths % 1000) / 10
let minor = tenths % 10
let superscripts: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
return String(format: "£%d.%02d", whole, major) + String(superscripts[minor]) + "/L"
}
}
}
struct ContentView: View {
@Environment(\.scenePhase) private var scenePhase
@State private var fuel: WatchFuelType = WatchFuelCache.loadSelectedFuel()
@State private var refreshState: WatchRefreshState = .noData
@State private var favourites: [WatchFavouriteEntry] = []
@State private var stations: [WatchStation] = []
@State private var lastLocation: WatchCoordinate?
@State private var distanceUnit: WatchDistanceUnit = WatchFuelCache.loadDistanceUnit()
@State private var priceStyle: WatchPriceStyle = WatchFuelCache.loadPriceStyle()
private var displayedFuels: [WatchFuelType] {
let fuels = Set(favourites.map(\.fuel))
let sorted = fuels.sorted { $0.sortRank < $1.sortRank }
return sorted.isEmpty ? [fuel] : sorted
}
private var hasAnyFavourites: Bool {
!favourites.isEmpty
}
var body: some View {
Group {
if hasAnyFavourites {
TabView(selection: $fuel) {
ForEach(displayedFuels) { fuelCase in
fuelPage(for: fuelCase)
.tag(fuelCase)
}
}
.tabViewStyle(.verticalPage)
} else {
emptyStateView
}
}
.onAppear {
reloadFromCache()
WatchSyncManager.shared.requestSnapshot()
}
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchDataDidUpdate)) { _ in
reloadFromCache()
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
reloadFromCache()
WatchSyncManager.shared.requestSnapshot()
}
}
}
@ViewBuilder
private func fuelPage(for fuelCase: WatchFuelType) -> some View {
let fuelFavourites = favouritesForFuel(fuelCase)
let cheapest = WatchFuelCache.cheapestFavourite(in: fuelFavourites, fuel: fuelCase)
let rows = rows(for: fuelCase)
ScrollView {
VStack(alignment: .leading, spacing: 12) {
header(for: fuelCase)
if let cheapest {
cheapestCard(for: fuelCase, favourite: cheapest)
}
statusRow
Button {
runCheckNow()
} label: {
HStack {
Image(systemName: refreshState.icon)
Text("Check now")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(fuelCase.color)
VStack(alignment: .leading, spacing: 8) {
Text("Favourites")
.font(.headline)
ForEach(rows) { row in
favouriteRow(row, fuel: fuelCase)
}
}
}
.padding(.horizontal, 10)
.padding(.vertical, 12)
}
}
private func favouritesForFuel(_ fuelCase: WatchFuelType) -> [WatchFavouriteEntry] {
favourites.filter { $0.fuel == fuelCase }
}
private func rows(for fuelCase: WatchFuelType) -> [WatchFavouriteRow] {
let fuelFavourites = favouritesForFuel(fuelCase)
let cheapest = WatchFuelCache.cheapestFavourite(in: fuelFavourites, fuel: fuelCase)
return fuelFavourites.map { favourite in
let distanceText = lastLocation.map { distanceUnit.format(km: favourite.station.distanceKM(to: $0)) }
let priceText = favourite.station.prices[fuelCase].map { WatchFuelCache.priceText($0, style: priceStyle) } ?? "—"
return WatchFavouriteRow(
id: favourite.id,
name: favourite.station.name,
priceText: priceText,
distanceText: distanceText,
isCheapest: favourite.id == cheapest?.id,
latitude: favourite.station.lat,
longitude: favourite.station.lng
)
}
}
private func header(for fuel: WatchFuelType) -> some View {
HStack(alignment: .center, spacing: 8) {
Image(systemName: fuel.symbol)
.foregroundStyle(fuel.color)
VStack(alignment: .leading, spacing: 1) {
Text("FuelBoard")
.font(.headline)
Text(fuel.displayName)
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
}
}
private var emptyStateView: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .center, spacing: 8) {
Image(systemName: "fuelpump.fill")
.foregroundStyle(Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0))
Text("FuelBoard")
.font(.headline)
Spacer()
}
statusRow
Button {
runCheckNow()
} label: {
HStack {
Image(systemName: refreshState.icon)
Text("Check now")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0))
VStack(alignment: .leading, spacing: 6) {
Text("No favourites yet")
.font(.headline)
Text("Add favourites on iPhone and they’ll appear here.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.padding(.horizontal, 10)
.padding(.vertical, 12)
}
}
private func cheapestCard(for fuel: WatchFuelType, favourite: WatchFavouriteEntry) -> some View {
Button {
openMaps(latitude: favourite.station.lat, longitude: favourite.station.lng)
} label: {
VStack(alignment: .leading, spacing: 6) {
Text("Cheapest favourite")
.font(.caption2)
.foregroundStyle(.secondary)
Text(favourite.station.name)
.font(.headline)
.lineLimit(2)
HStack(alignment: .firstTextBaseline) {
Text(favourite.station.prices[fuel].map { WatchFuelCache.priceText($0, style: priceStyle) } ?? "—")
.font(.title3)
.fontWeight(.bold)
Spacer()
if let location = lastLocation {
Text(distanceUnit.format(km: favourite.station.distanceKM(to: location)))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
}
.buttonStyle(.plain)
.background(fuel.color.opacity(0.15), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
private var statusRow: some View {
Label(refreshState.label, systemImage: refreshState.icon)
.font(.caption)
.foregroundStyle(refreshState.tint)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func favouriteRow(_ favourite: WatchFavouriteRow, fuel: WatchFuelType) -> some View {
Button {
openMaps(latitude: favourite.latitude, longitude: favourite.longitude)
} label: {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(favourite.name)
.font(.caption)
.fontWeight(favourite.isCheapest ? .semibold : .regular)
.lineLimit(1)
if favourite.isCheapest {
Text("TOP")
.font(.system(size: 9, weight: .bold))
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(fuel.color.opacity(0.18), in: Capsule())
}
}
if let distanceText = favourite.distanceText {
Text(distanceText)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
Spacer()
Text(favourite.priceText)
.font(.callout.monospacedDigit())
.fontWeight(.semibold)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(.plain)
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private func reloadFromCache() {
stations = WatchFuelCache.loadStations()
favourites = WatchFuelCache.refreshedFavourites(WatchFuelCache.loadFavourites(), from: stations)
distanceUnit = WatchFuelCache.loadDistanceUnit()
priceStyle = WatchFuelCache.loadPriceStyle()
lastLocation = WatchFuelCache.loadLocation()
refreshState = statusFromCache()
let availableFuels = Set(favourites.map(\.fuel))
if !availableFuels.isEmpty, !availableFuels.contains(fuel) {
fuel = availableFuels.sorted { $0.sortRank < $1.sortRank }.first ?? .e10
}
}
private func statusFromCache() -> WatchRefreshState {
let hasUsablePrices = !stations.isEmpty
|| favourites.contains { favourite in favourite.station.prices[fuel] != nil }
guard hasUsablePrices else { return .noData }
guard let lastRefresh = WatchFuelCache.loadLastRefresh() else { return .stale(lastUpdated: "earlier") }
let formatted = relativeRefreshText(for: lastRefresh)
return WatchFuelCache.isCacheFresh(lastRefresh: lastRefresh)
? .idle(lastUpdated: formatted)
: .stale(lastUpdated: formatted)
}
private func runCheckNow() {
refreshState = .checking
let previousLastRefresh = WatchFuelCache.loadLastRefresh()
let requestDate = Date()
WatchFuelCache.requestRefresh(requestDate)
WatchSyncManager.shared.requestRefresh(at: requestDate)
Task {
for _ in 0..<12 {
try? await Task.sleep(for: .seconds(1))
if WatchFuelCache.wasRefreshHandled(since: requestDate) {
await MainActor.run {
reloadFromCache()
let currentLastRefresh = WatchFuelCache.loadLastRefresh()
if let currentLastRefresh,
currentLastRefresh >= requestDate,
currentLastRefresh > (previousLastRefresh ?? .distantPast) {
refreshState = .requested(lastUpdated: relativeRefreshText(for: currentLastRefresh))
} else if let currentLastRefresh {
refreshState = .checkedNoNewData(lastUpdated: relativeRefreshText(for: currentLastRefresh))
} else {
refreshState = .noData
}
}
return
}
}
await MainActor.run {
reloadFromCache()
refreshState = .needsPhone
}
}
}
private func relativeRefreshText(for date: Date) -> String {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .short
return formatter.localizedString(for: date, relativeTo: Date())
}
private func openMaps(latitude: Double, longitude: Double) {
let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
let destination = MKMapItem(placemark: MKPlacemark(coordinate: coordinate))
_ = MKMapItem.openMaps(
with: [MKMapItem.forCurrentLocation(), destination],
launchOptions: [
MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving
]
)
}
}
#Preview {
ContentView()
}
@@ -0,0 +1,14 @@
import SwiftUI
@main
struct FuelBoardWatchApp: App {
init() {
WatchSyncManager.shared.activate()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
@@ -0,0 +1,101 @@
import Foundation
import WatchConnectivity
extension Notification.Name {
static let fuelBoardWatchDataDidUpdate = Notification.Name("FuelBoardWatchDataDidUpdate")
}
final class WatchSyncManager: NSObject, WCSessionDelegate {
static let shared = WatchSyncManager()
private let suiteName = "group.com.apt.fuelboard"
private let snapshotRequestKey = "fuelboard.snapshotRequest"
private override init() {
super.init()
}
func activate() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
if session.delegate !== self {
session.delegate = self
}
session.activate()
}
func requestRefresh(at date: Date = Date()) {
guard WCSession.isSupported() else { return }
let raw = String(date.timeIntervalSince1970)
let payload = ["fuelboard.watchRefreshRequest": raw]
let session = WCSession.default
if session.isReachable {
session.sendMessage(payload, replyHandler: nil, errorHandler: nil)
} else {
session.transferUserInfo(payload)
}
}
func requestSnapshot() {
guard WCSession.isSupported() else { return }
let payload = [snapshotRequestKey: String(Date().timeIntervalSince1970)]
let session = WCSession.default
if session.isReachable {
session.sendMessage(payload, replyHandler: nil, errorHandler: nil)
} else {
session.transferUserInfo(payload)
}
}
private func sharedDefaults() -> UserDefaults? {
UserDefaults(suiteName: suiteName)
}
private func storeSnapshot(_ payload: [String: Any]) {
guard let defaults = sharedDefaults() else { return }
if let favourites = payload["fuelboard.favourites"] as? Data {
defaults.set(favourites, forKey: "fuelboard.favourites")
}
if let fuel = payload["fuelboard.selectedFuel"] as? String {
defaults.set(fuel, forKey: "fuelboard.selectedFuel")
}
if let distanceUnit = payload["fuelboard.distanceUnit"] as? String {
defaults.set(distanceUnit, forKey: "fuelboard.distanceUnit")
}
if let priceStyle = payload["fuelboard.priceDisplayStyle"] as? String {
defaults.set(priceStyle, forKey: "fuelboard.priceDisplayStyle")
}
if let location = payload["fuelboard.lastLocation"] as? String {
defaults.set(location, forKey: "fuelboard.lastLocation")
}
if let lastRefresh = payload["fuelboard.lastRefresh"] as? String {
defaults.set(lastRefresh, forKey: "fuelboard.lastRefresh")
}
if let handled = payload["fuelboard.watchRefreshHandled"] as? String {
defaults.set(handled, forKey: "fuelboard.watchRefreshHandled")
}
NotificationCenter.default.post(name: .fuelBoardWatchDataDidUpdate, object: nil)
}
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
if let error {
print("WATCH-SYNC activation failed: \(error.localizedDescription)")
return
}
if activationState == .activated {
requestSnapshot()
}
}
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) {
storeSnapshot(applicationContext)
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
storeSnapshot(userInfo)
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,140 @@
{
"images": [
{
"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
}
}
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

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+129 -37
View File
@@ -2,29 +2,28 @@
//
// Lives in the widget extension (the standard host for ActivityConfiguration).
// Shows the cheapest station for the pinned fuel within the app's chosen
// radius. Tapping anywhere opens Apple Maps directions to that station.
// radius. Tapping honours Settings → Tap station: Open map opens Apple Maps
// directions to that station, More info opens the detail sheet in the app.
//
// ADAPTIVE LAYOUT: ActivityConfiguration shares ONE content view across the
// Lock Screen, banner, and the CarPlay small slot — there is no per-platform
// closure. So this view provides two layouts and lets ViewThatFits pick by
// available width:
// • richBody — the full three-column design (glyph · fuel+station · price),
// wins wherever there's Lock Screen width (it carries an
// explicit minWidth so it can never be squeezed into the car).
// • compactBody — a minimal price-strip (glyph+fuel+price, station caption
// below) that wins in the CarPlay/Apple Watch Smart Stack
// small slot.
// ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
// ViewThatFits pick by available width, but the narrow `.small` family is NOT
// declared (see note on the config), so iPhone/iPad always render the full
// three-column card (`richBody`) with proper text sizes. `compactBody` is kept
// as a safety fallback should any surface ever hand this view a narrow width.
//
// IMPORTANT: `.supplementalActivityFamilies([.small])` is deliberately absent —
// it made iOS render the squeezed `.small` card on the iPhone Lock Screen.
// CarPlay's small form comes from the Dynamic Island compact closures.
import ActivityKit
import SwiftUI
import WidgetKit
/// The Live Activity itself — registered in the widget bundle alongside the
/// regular price widget. No CarPlay entitlement involved: this renders on the
/// Lock Screen, Dynamic Island, and the car display (CarPlay Ultra, iOS 26+).
/// `.supplementalActivityFamilies([.small])` makes it eligible for the car's
/// small Live Activity slot — display-only there (FuelBoard is not a
/// CarPlay-enabled app, so car-side taps can't launch anything).
/// regular price widget. No CarPlay entitlement involved: renders on the
/// Lock Screen (full-width card on iPhone/iPad) and the Dynamic Island
/// (incl. the island's compact form used in the car, display-only — FuelBoard
/// is not a CarPlay-enabled app, so car-side taps can't launch anything).
struct FuelBoardLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: FuelBoardLiveActivityAttributes.self) { context in
@@ -33,25 +32,43 @@ struct FuelBoardLiveActivity: Widget {
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
FuelBoardLiveActivityCompactView(context: context)
}
}
DynamicIslandExpandedRegion(.trailing) {
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
FuelBoardLiveActivityPriceView(context: context)
}
}
DynamicIslandExpandedRegion(.bottom) {
FuelBoardLiveActivityStationView(context: context)
}
} compactLeading: {
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
Image(systemName: "fuelpump.fill")
.foregroundStyle(.green)
.foregroundStyle(context.state.fuel.tintColor)
}
} compactTrailing: {
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
FuelBoardLiveActivityPriceView(context: context)
}
} minimal: {
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
Text(context.state.priceText)
.font(.caption2.bold().monospacedDigit())
}
}
}
.supplementalActivityFamilies([.small])
// Why `.small` is kept: it lets the SHARED body render a compact form
// in genuinely small slots (CarPlay small / Apple Watch smart stack)
// instead of falling back to the Dynamic Island compact closure —
// which could NOT show the station distance the user wants on CarPlay.
// The full-width iPhone/iPad card is protected by the `richMinWidth`
// gate on `richBody` + its flexible, truncating middle column, so
// iPhone/iPad still get the full card; only truly small space picks
// the compact strip below.
}
}
@@ -59,27 +76,59 @@ struct FuelBoardLiveActivity: Widget {
private struct FuelBoardLiveActivityView: View {
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
/// Below this ACTUAL proposed width we show the compact strip (CarPlay
/// small / Watch smart stack); at/above it we show the full card. The
/// decision is made from the real width the system hands the body, read
/// via a background GeometryReader — NOT ViewThatFits ideal-width
/// measurement (that's broken for truncating text: a long station name
/// inflated the "ideal" width past the iPhone Lock Screen and collapsed
/// the full card).
private let compactWidthThreshold: CGFloat = 280
/// Measured slot width (drives the rich-vs-compact branch). Measured in a
/// background GeometryReader so it does NOT act as the layout container:
/// a GeometryReader root pins content top-left, and forcing a
/// maxHeight:.infinity frame on it over-claims the whole proposed height,
/// centring the content below true vertical centre (bigger gap above) on
/// the Lock Screen. Measuring behind the scenes keeps the content
/// intrinsic-sized so the system vertically centres it itself.
@State private var slotWidth: CGFloat = 400
var body: some View {
Link(destination: context.state.mapsURL) {
ViewThatFits(in: .horizontal) {
// rich first — wins on full-width Lock Screen / banner
Link(destination: tapDestination) {
Group {
// Branch on the ACTUAL proposed width. iPhone/iPad offer the
// full Lock Screen width (>= threshold) → rich card, no matter
// how long the station name is. Truly small slots (CarPlay /
// Watch) offer much less → compact strip.
if slotWidth >= compactWidthThreshold {
richBody
// CarPlay / Watch small slot is far narrower than this,
// so ViewThatFits reliably falls through to compactBody.
.frame(minWidth: 280)
// compact fallback — the car's small supplemental slot
} else {
compactBody
}
}
// Fill the card width so the background measure reads the real
// slot, not the intrinsic content width.
.frame(maxWidth: .infinity)
// Side-channel width measurement — never the layout container.
.background(
GeometryReader { geo in
Color.clear
.onAppear { slotWidth = geo.size.width }
.onChange(of: geo.size.width) { _, w in slotWidth = w }
}
)
}
}
/// Full three-column design (unchanged): brand glyph · fuel+station · price.
private var richBody: some View {
HStack(spacing: 12) {
// LEFT — station brand glyph
Image(systemName: "fuelpump.circle.fill")
.font(.system(size: 32))
.foregroundStyle(.green, .white)
// LEFT — station brand glyph: the fuel-tinted pump on its own. No
// background circle behind it (user request).
Image(systemName: "fuelpump.fill")
.font(.system(size: 28, weight: .semibold))
.foregroundStyle(context.state.fuel.tintColor)
.frame(width: 40, height: 40)
// MIDDLE — fuel + station
@@ -90,6 +139,8 @@ private struct FuelBoardLiveActivityView: View {
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.75)
.truncationMode(.tail)
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -98,7 +149,8 @@ private struct FuelBoardLiveActivityView: View {
FuelStore.priceTextAttributed(context.state.pricePence,
style: context.state.priceDisplayStyle,
size: 22, weight: .bold)
Text("Tap for directions")
Text(FuelStore.loadStationTapAction() == .showDetails
? "Tap for details" : "Tap for directions")
.font(.caption2)
.foregroundStyle(.secondary)
}
@@ -106,16 +158,20 @@ private struct FuelBoardLiveActivityView: View {
.padding()
}
/// Minimal strip for the small CarPlay / Watch Smart Stack slot:
/// glyph + fuel left, bold price right, truncated station below.
/// Minimal strip for small space (CarPlay small / Watch smart stack):
/// fuel type + distance + bold price on one line, station name below.
/// Deliberately no app name and no full address — the watch-sized slot is
/// too tight, so the second line is reserved for the station name only.
private var compactBody: some View {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 5) {
Image(systemName: "fuelpump.fill")
.font(.caption2)
.foregroundStyle(.green)
Text(context.state.fuel.displayName)
.font(.caption2.weight(.semibold))
.font(.caption.bold())
.lineLimit(1)
Spacer(minLength: 4)
Text(context.state.distanceText)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 4)
FuelStore.priceTextAttributed(context.state.pricePence,
@@ -123,10 +179,12 @@ private struct FuelBoardLiveActivityView: View {
size: 15, weight: .bold)
.lineLimit(1)
}
Text("\(context.state.stationName) · \(context.state.distanceText)")
.font(.system(size: 9))
Text(context.state.stationName)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.75)
.truncationMode(.tail)
}
.padding(8)
}
@@ -164,11 +222,28 @@ private struct FuelBoardLiveActivityStationView: View {
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
var body: some View {
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
Text("\(context.state.stationName) · \(context.state.distanceText)")
.font(.caption)
.lineLimit(1)
}
}
}
/// Tap routing shared by the Lock Screen card and the island: honours
/// Settings → Tap station (Open map → Maps, More info → in-app detail).
enum FuelBoardLiveActivityTap {
static func tapDestination(for state: FuelBoardLiveActivityAttributes.ContentState) -> URL {
FuelStore.loadStationTapAction() == .showDetails ? state.detailURL : state.mapsURL
}
}
private extension FuelBoardLiveActivityView {
/// Lock Screen tap honours Settings → Tap station.
var tapDestination: URL {
FuelBoardLiveActivityTap.tapDestination(for: context.state)
}
}
// MARK: - ContentState display helpers
@@ -189,4 +264,21 @@ extension FuelBoardLiveActivityAttributes.ContentState {
var mapsURL: URL {
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")!
}
/// In-app deep link to the station's detail sheet, used when
/// Settings → Tap station = More info. Carries coordinate/name fallback
/// so the app can still show the map sheet when the station has left
/// the cache.
var detailURL: URL {
var components = URLComponents()
components.scheme = "fuelboard"
components.host = "station"
components.queryItems = [
URLQueryItem(name: "stationID", value: stationID),
URLQueryItem(name: "lat", value: String(lat)),
URLQueryItem(name: "lng", value: String(lng)),
URLQueryItem(name: "name", value: stationName),
]
return components.url!
}
}
+39
View File
@@ -0,0 +1,39 @@
# FuelBoard Release Notes
## Next TestFlight build
### Summary
- Added a new Data checking setting with Standard and Smart modes.
- Clarified the behavior and background-update expectations in Settings.
### What changed
- Added a dedicated **Data checking** section in Settings.
- Added two user-facing modes:
- **Standard** — Saves battery. Refreshes price data up to twice daily.
- **Smart** — Checks for newer data more often in the background and refreshes full prices only when an update is available.
- Smart mode now uses a lightweight metadata probe first, then only refreshes the full dataset if newer data is available.
- Added clear explanatory copy that background checks are best-effort and happen only when iOS allows.
- Kept larger refresh behavior conservative rather than implying exact hourly updates.
### Why
- The previous refresh model was conservative but not very configurable.
- The new setting makes the behavior clearer and gives users a choice between battery-friendly default behavior and a smarter background-check mode.
- The wording is designed to be honest about iOS background limits.
### Suggested TestFlight notes
- Added new Data checking modes in Settings: Standard and Smart.
- Smart mode now checks for newer data more intelligently in the background when iOS allows.
- Improved settings copy to better explain data freshness behavior.
### Suggested What to Test
- Open Settings and confirm **Data checking** appears as its own section.
- Confirm the two options read clearly and feel understandable:
- Standard
- Smart
- Switch between Standard and Smart and confirm the selection persists.
- Confirm the explanatory copy feels accurate and not misleading about iOS background behavior.
### Implementation note
- Standard keeps the existing cache-first refresh behavior.
- Smart adds a lightweight probe path before deciding whether to download the full data set.
- Background timing remains best-effort under iOS scheduling.
+24
View File
@@ -0,0 +1,24 @@
import Foundation
enum DataRefreshMode: String, CaseIterable, Identifiable {
case standard
case smart
var id: String { rawValue }
var displayName: String {
switch self {
case .standard: return "Standard"
case .smart: return "Smart"
}
}
var summary: String {
switch self {
case .standard:
return "Saves battery. Refreshes price data up to twice daily."
case .smart:
return "Checks for newer data more often in the background and refreshes full prices only when an update is available."
}
}
}
+3 -1
View File
@@ -9,7 +9,9 @@
// in Settings → Live Activity (INDEPENDENT of the Stations-tab selection).
// Everything dynamic lives in ContentState so updates — including a fuel
// change — apply to the running activity without needing to restart it.
// Tapping the activity opens Apple Maps driving directions (maps://).
// Tapping the activity honours Settings → Tap station: Open map opens Apple
// Maps driving directions (maps://), More info opens the detail sheet in
// the app (fuelboard://station).
import ActivityKit
import Foundation
+11 -3
View File
@@ -216,10 +216,18 @@ enum FuelHistoryStore {
// MARK: Network
/// The mirror pointer — used for the empty-state hint ("first snapshot
/// landed …"). Non-fatal: nil just means no hint.
static func fetchLatest(base: URL = mirrorBase,
/// landed …"). Non-fatal: nil just means no hint. Deliberately bypasses
/// the HTTP cache (reloadIgnoringLocalCacheData) so an OFFLINE fetch
/// really fails instead of silently re-serving a cached pointer as a
/// success — otherwise "no network" would look like fresh data and the
/// offline banner would never fire. A short timeout surfaces the failure
/// (and the banner) quickly on a connected-but-dead network.
static func fetchLatest(base: URL = mirrorBase, timeout: TimeInterval = 10,
session: URLSession = .shared) async -> MirrorLatest? {
guard let (data, response) = try? await session.data(from: latestFileURL(base: base)),
var req = URLRequest(url: latestFileURL(base: base))
req.cachePolicy = .reloadIgnoringLocalCacheData
req.timeoutInterval = timeout
guard let (data, response) = try? await session.data(for: req),
(response as? HTTPURLResponse)?.statusCode == 200,
let latest = try? JSONDecoder().decode(MirrorLatest.self, from: data) else {
return nil
+6 -5
View File
@@ -89,17 +89,18 @@ struct FuelPriceWidgetContent: View {
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
}
if let location = entry.location {
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)) + " away")
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)) + " away")
.font(.caption2)
.foregroundStyle(.secondary)
} else {
Text("Tap for directions")
Text(FuelStore.loadStationTapAction() == .showDetails
? "Tap for details" : "Tap for directions")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.modifier(WidgetURLModifier(url: isWidget ? station.widgetDirectionsURL : nil))
.modifier(WidgetURLModifier(url: isWidget ? station.tapDestinationURL : nil))
}
/// How many station rows each family can fit: medium widgets are short
@@ -129,7 +130,7 @@ struct FuelPriceWidgetContent: View {
}
ForEach(entry.stations.prefix(maxRows)) { station in
if isWidget {
Link(destination: station.widgetDirectionsURL ?? URL(string: "maps://")!) {
Link(destination: station.tapDestinationURL ?? URL(string: "maps://")!) {
row(station, cheapest: cheapest)
}
.buttonStyle(.plain)
@@ -148,7 +149,7 @@ struct FuelPriceWidgetContent: View {
.font(.caption.weight(.semibold))
.lineLimit(1)
if let location = entry.location {
Text(entry.unit.format(station.distanceKM(to: location.lat, lng2: location.lng)))
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
.font(.caption2)
.foregroundStyle(.secondary)
}
+279 -7
View File
@@ -8,6 +8,7 @@
// keychain → app-group defaults → fallback.
import Foundation
import SwiftUI
import Security
#if canImport(AppIntents)
import AppIntents
@@ -99,6 +100,20 @@ enum FuelType: String, Codable, CaseIterable, Identifiable {
}
}
/// Fuel colour wheel (user-chosen palette): green = unleaded (#30D158),
/// yellow = premium (#FFD60A), cyan = diesel (#64D2FF). Lives here in Shared
/// so the app, widget, and Live Activity all tint the pump/fuel glyphs from one
/// definition.
extension FuelType {
var tintColor: Color {
switch self {
case .e10: return Color(red: 48/255.0, green: 209/255.0, blue: 88/255.0) // #30D158
case .e5: return Color(red: 255/255.0, green: 214/255.0, blue: 10/255.0) // #FFD60A
case .diesel: return Color(red: 100/255.0, green: 210/255.0, blue: 255/255.0) // #64D2FF
}
}
}
#if canImport(AppIntents)
extension FuelType: AppEnum {}
#endif
@@ -193,6 +208,25 @@ enum PriceDisplayStyle: String, Codable, CaseIterable, Identifiable {
}
}
// MARK: - Station tap action
/// What happens when a station row is tapped in the Stations/Favourites tabs:
/// open Apple Maps directions immediately (historic behaviour, default), or
/// show a detail sheet (name, address, distance, Directions button).
enum StationTapAction: String, Codable, CaseIterable, Identifiable {
case openMap // tap → Apple Maps directions straight away
case showDetails // tap → detail sheet, Directions button inside
var id: String { rawValue }
var displayName: String {
switch self {
case .openMap: return "Open map"
case .showDetails: return "More info"
}
}
}
// MARK: - Station model
struct FuelStation: Identifiable, Codable, Equatable {
@@ -237,6 +271,30 @@ struct FuelStation: Identifiable, Codable, Equatable {
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")
}
/// In-app deep link to this station's More info sheet. Carries the
/// stationID plus coordinate/name fallback so the app can still show the
/// map sheet when the station has left the cache.
var detailDeepLinkURL: URL? {
var components = URLComponents()
components.scheme = "fuelboard"
components.host = "station"
components.queryItems = [
URLQueryItem(name: "stationID", value: id),
URLQueryItem(name: "lat", value: String(lat)),
URLQueryItem(name: "lng", value: String(lng)),
URLQueryItem(name: "name", value: name),
]
return components.url
}
/// Widget/Live Activity tap destination honouring Settings → Tap station:
/// Open map → Apple Maps directions; More info → in-app detail deep link.
var tapDestinationURL: URL? {
FuelStore.loadStationTapAction() == .showDetails
? detailDeepLinkURL
: widgetDirectionsURL
}
/// Name of the bundled brand logo asset, or nil if unknown.
/// Normalizes messy raw brand strings ("SHELL LEEDS ROAD" → "shell").
var brandImageName: String? {
@@ -355,6 +413,10 @@ struct FuelStore {
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
static let onboardingCompletedKey = "fuelboard.onboardingCompleted" // Bool
static let lastRefreshKey = "fuelboard.lastRefresh" // TimeInterval (seconds since 1970)
static let dataRefreshModeKey = "fuelboard.dataRefreshMode" // DataRefreshMode raw value
static let lastSmartProbeKey = "fuelboard.lastSmartProbe" // TimeInterval (seconds since 1970)
static let watchRefreshRequestKey = "fuelboard.watchRefreshRequest" // TimeInterval (seconds since 1970)
static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled" // TimeInterval (seconds since 1970)
static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv"
static let stationCountKey = "fuelboard.stationCount" // String — station count
static let dataUpdatedKey = "fuelboard.dataUpdated" // String — govUK dataset update time
@@ -523,6 +585,22 @@ struct FuelStore {
saveString(style.rawValue, service: priceDisplayStyleKey)
}
// MARK: Station tap action — open map vs detail sheet. Stored raw value;
// default open map preserves the historic tap behaviour for existing installs.
static let stationTapActionKey = "fuelboard.stationTapAction"
static func loadStationTapAction() -> StationTapAction {
if let raw = loadString(service: stationTapActionKey), let action = StationTapAction(rawValue: raw) {
return action
}
return .openMap
}
static func saveStationTapAction(_ action: StationTapAction) {
saveString(action.rawValue, service: stationTapActionKey)
}
/// Superscript digit glyphs for the pounds & pence format's small raised
/// third digit (the forecourt style: "£1.29⁹").
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
@@ -846,10 +924,24 @@ struct FuelStore {
saveString(enabled ? "1" : "0", service: liveActivityFollowSearchKey)
}
// MARK: Refresh policy — data is cached; the app only auto-refreshes
// twice a day (pull-to-refresh is the manual override).
// MARK: Refresh policy — Standard is cache-first/twice-daily; Smart keeps
// the same full-dump freshness cap but may probe the tiny mirror pointer
// more often in the background and only refresh the full dump if it changed.
static let refreshInterval: TimeInterval = 12 * 60 * 60
static let smartProbeInterval: TimeInterval = 4 * 60 * 60
static func loadDataRefreshMode() -> DataRefreshMode {
if let raw = loadString(service: dataRefreshModeKey),
let mode = DataRefreshMode(rawValue: raw) {
return mode
}
return .standard
}
static func saveDataRefreshMode(_ mode: DataRefreshMode) {
saveString(mode.rawValue, service: dataRefreshModeKey)
}
static func loadLastRefresh() -> Date? {
if let raw = loadString(service: lastRefreshKey), let ts = TimeInterval(raw) {
@@ -862,6 +954,45 @@ struct FuelStore {
saveString(String(date.timeIntervalSince1970), service: lastRefreshKey)
}
static func loadLastSmartProbe() -> Date? {
if let raw = loadString(service: lastSmartProbeKey), let ts = TimeInterval(raw) {
return Date(timeIntervalSince1970: ts)
}
return nil
}
static func saveLastSmartProbe(_ date: Date = Date()) {
saveString(String(date.timeIntervalSince1970), service: lastSmartProbeKey)
}
static func requestWatchRefresh(_ date: Date = Date()) {
saveString(String(date.timeIntervalSince1970), service: watchRefreshRequestKey)
}
static func loadWatchRefreshRequest() -> Date? {
if let raw = loadString(service: watchRefreshRequestKey), let ts = TimeInterval(raw) {
return Date(timeIntervalSince1970: ts)
}
return nil
}
static func markWatchRefreshHandled(_ date: Date = Date()) {
saveString(String(date.timeIntervalSince1970), service: watchRefreshHandledKey)
}
static func loadWatchRefreshHandled() -> Date? {
if let raw = loadString(service: watchRefreshHandledKey), let ts = TimeInterval(raw) {
return Date(timeIntervalSince1970: ts)
}
return nil
}
static var hasPendingWatchRefreshRequest: Bool {
guard let requested = loadWatchRefreshRequest() else { return false }
guard let handled = loadWatchRefreshHandled() else { return true }
return requested > handled
}
// MARK: Relay metadata — shown in Settings → About. Written after each
// successful full fetch so the About section reflects the live source.
@@ -914,20 +1045,141 @@ struct FuelStore {
/// True when the cached data is fresh enough that a scheduled auto-refresh
/// should be skipped (twice-a-day policy).
static var isCacheFresh: Bool {
isCacheFresh(now: Date())
}
static func isCacheFresh(now: Date) -> Bool {
guard let last = loadLastRefresh() else { return false }
return Date().timeIntervalSince(last) < refreshInterval
return now.timeIntervalSince(last) < refreshInterval
}
static func isSmartProbeDue(now: Date = Date()) -> Bool {
guard let last = loadLastSmartProbe() else { return true }
return now.timeIntervalSince(last) >= smartProbeInterval
}
// MARK: Onboarding — the app shows the intro screen on first launch only
// (a test button in the Alerts tab re-opens it). Stored in the app group
// so the widget can see it too if ever needed.
// (a test button in the Alerts tab re-opens it). Stored KEYCHAIN-FIRST
// (with an app-group mirror) for the same reason as favourites/distance
// unit: free SideStore accounts don't provision the app-group container,
// so an app-group-only flag silently fails to save AND reloads as false,
// making onboarding re-appear on every launch. Keychain survives reinstall
// and is shared with the extension.
static func loadHasCompletedOnboarding() -> Bool {
UserDefaults(suiteName: appGroupSuite)?.bool(forKey: onboardingCompletedKey) ?? false
(loadString(service: onboardingCompletedKey) ?? "0") == "1"
}
static func saveHasCompletedOnboarding(_ completed: Bool) {
UserDefaults(suiteName: appGroupSuite)?.set(completed, forKey: onboardingCompletedKey)
saveString(completed ? "1" : "0", service: onboardingCompletedKey)
}
// MARK: Install identity — distinguish a genuinely fresh install (where
// onboarding should replay) from later launches of the same install.
/// Keychain copy of the install id (survives reinstall).
private static let installIDKey = "fuelboard.installID"
/// Local (app-own container) copy — wiped on reinstall, persists across
/// normal launches. Free SideStore accounts have no app-group container,
/// so this app-own defaults domain is the reliable "same install" signal.
private static let localInstallIDKey = "fuelboard.installID.local"
/// True when this is a first-ever install OR the app was just reinstalled
/// (local install id missing/different from the keychain id). Seeds a fresh
/// id into both stores so the next launch within the same install is not a
/// "fresh install" any more.
static func isFreshInstall() -> Bool {
let local = UserDefaults.standard.string(forKey: localInstallIDKey)
let remote = loadString(service: installIDKey)
if let local, let remote, local == remote { return false }
// Fresh / mismatched install (or first launch). Pattern a common id so
// subsequent launches of this install are recognised as the same one.
let id = UUID().uuidString
UserDefaults.standard.set(id, forKey: localInstallIDKey)
saveString(id, service: installIDKey)
return true
}
/// Onboarding should present when it hasn't been completed in this install
/// OR this is a freshly-installed app (so the walkthrough replays on a new
/// install, e.g. after a SideStore reinstall, without re-showing on every
/// ordinary launch). The install check always runs (it seeds the id) rather
/// than short-circuiting, so a brand-new install is recorded before the
/// user ever fills in onboarding.
static func shouldShowOnboarding() -> Bool {
let fresh = isFreshInstall()
return !loadHasCompletedOnboarding() || fresh
}
// MARK: Road distances (Apple-Maps-matched, computed by the app)
/// Cached road/routed distances (metres) keyed by station ID, computed by
/// the app via MapKit `MKDirections`. Stored in KEYCHAIN (survives on free
/// SideStore accounts where the app-group container isn't provisioned) so
/// the widget extension can read it too. Widget + Live Activity prefer
/// these over straight-line haversine for the displayed distance.
static let roadDistancesKey = "fuelboard.roadDistances"
/// How far (metres) the cache's source location may be from the current
/// user position before a cached road distance is treated as stale.
static let roadDistanceOriginToleranceMeters: Double = 600
/// How far (degrees) a station's stored coordinate may drift from the pin
/// a road distance was actually routed to before that cached value is
/// treated as belonging to a different station. Guards against one data
/// source (live fetch, bundled offline dump, or a corrected pin) serving a
/// road distance that was computed for a different coordinate under the
/// same station ID. ~1e-4 deg ≈ 11 m — tolerates float/rounding jitter but
/// catches any real pin change.
static let roadDistancePinToleranceDegrees: Double = 1e-4
static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: CachedRoadDistance]) {
let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng,
updatedAt: Date().timeIntervalSince1970, entries: entries)
if let data = try? JSONEncoder().encode(cache) {
saveString(data.base64EncodedString(), service: roadDistancesKey)
}
}
static func loadRoadDistances() -> RoadDistanceCache? {
guard let raw = loadString(service: roadDistancesKey),
let data = Data(base64Encoded: raw),
let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data)
else { return nil }
return cache
}
/// Cached road distance (metres) to a station from the user's location, or
/// nil when not cached / the cache was built too far from where the user
/// is now / the station's own pin doesn't match the coordinate that was
/// routed.
static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
guard let cache = loadRoadDistances(),
let entry = cache.entries[station.id] else { return nil }
// The cache is only valid near the location it was built from.
let dLat = (userLat - cache.sourceLat) * .pi / 180
let dLng = (userLng - cache.sourceLng) * .pi / 180
let r = 6371000.0
let a = sin(dLat / 2) * sin(dLat / 2) +
cos(cache.sourceLat * .pi / 180) * cos(userLat * .pi / 180) *
sin(dLng / 2) * sin(dLng / 2)
let originDistanceMeters = r * 2 * atan2(sqrt(a), sqrt(1 - a))
guard originDistanceMeters <= roadDistanceOriginToleranceMeters else { return nil }
// Pin fingerprint: never serve a routed value for a coordinate we
// didn't actually route to. This is the guard that keeps embedded /
// live / cached station sets from injecting each other's road metres.
guard abs(entry.lat - station.lat) <= roadDistancePinToleranceDegrees,
abs(entry.lng - station.lng) <= roadDistancePinToleranceDegrees else { return nil }
return entry.meters
}
/// Distance (km) to display for a station: cached ROAD distance when
/// available (matches Apple Maps), else straight-line haversine.
static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double {
if let meters = roadDistanceMeters(for: station, userLat: userLat, userLng: userLng) {
return meters / 1000.0
}
return station.distanceKM(to: userLat, lng2: userLng)
}
// MARK: Low-level keychain helpers
@@ -989,3 +1241,23 @@ struct FuelStore {
loadString(service: "widget.diag.\(intentType)")
}
}
/// Cached Apple-Maps road distances for nearby stations (see
/// A single cached road distance plus the station pin it was routed to. Keeping
/// the pin lets `roadDistanceMeters` refuse to serve a route computed for a
/// *different* coordinate under the same ID — the guard that stops embedded /
/// live / cached station sets cross-contaminating the distance display.
struct CachedRoadDistance: Codable {
let meters: Double
let lat: Double
let lng: Double
}
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres +
/// the routed pin.
struct RoadDistanceCache: Codable {
let sourceLat: Double
let sourceLng: Double
let updatedAt: TimeInterval
let entries: [String: CachedRoadDistance]
}
+7 -3
View File
@@ -96,9 +96,13 @@ struct MirrorFuelProvider: FuelPriceProviding {
if Self.canReuseCache(cachedDay: cache?.day, latestDay: day), let cached = cache?.data {
data = cached
} else {
let (fetched, response) = try await URLSession.shared.data(
from: FuelHistoryStore.historyFileURL(day: day, base: baseURL)
)
// Bypass the HTTP cache + short timeout: an offline refresh must
// FAIL (→ offline banner), never re-serve a stale cached dump as
// a "successful" live fetch.
var req = URLRequest(url: FuelHistoryStore.historyFileURL(day: day, base: baseURL))
req.cachePolicy = .reloadIgnoringLocalCacheData
req.timeoutInterval = 10
let (fetched, response) = try await URLSession.shared.data(for: req)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw FuelProviderError.mirrorUnavailable
}
+122
View File
@@ -0,0 +1,122 @@
#!/bin/bash
set -euo pipefail
cd "$(dirname "$0")/.."
PROJECT="$PWD/FuelBoard.xcodeproj"
SCHEME="FuelBoard"
DERIVED_DATA="/tmp/fuelboard-build-ipas"
SERVE_DIR="/tmp/ipa-serve"
FULL_IPA="$SERVE_DIR/FuelBoard-watch.ipa"
SIDELOAD_IPA="$SERVE_DIR/FuelBoard-sideload.ipa"
HOST="192.168.1.131"
PORT="8765"
APP_PATH="$DERIVED_DATA/Build/Products/Release-iphoneos/FuelBoard.app"
STAGE_FULL="/tmp/fuelboard-ipa-stage-full"
STAGE_SIDELOAD="/tmp/fuelboard-ipa-stage-sideload"
echo "==> Refresh bundled dump"
"$PWD/scripts/refresh_bundled_dump.sh"
echo "==> Clean build dirs"
rm -rf "$DERIVED_DATA" "$STAGE_FULL" "$STAGE_SIDELOAD"
mkdir -p "$SERVE_DIR"
echo "==> Build Release app (generic iOS destination)"
xcodebuild \
-project "$PROJECT" \
-scheme "$SCHEME" \
-configuration Release \
-destination 'generic/platform=iOS' \
-derivedDataPath "$DERIVED_DATA" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY='' \
build >/tmp/fuelboard-build-ipas-xcodebuild.log
if [ ! -d "$APP_PATH" ]; then
echo "missing built app: $APP_PATH" >&2
exit 1
fi
echo "==> Package full watch-capable IPA"
python3 - <<'PY'
import os, shutil, zipfile
app = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
stage = '/tmp/fuelboard-ipa-stage-full'
payload = os.path.join(stage, 'Payload')
out = '/tmp/ipa-serve/FuelBoard-watch.ipa'
if os.path.exists(stage):
shutil.rmtree(stage)
os.makedirs(payload, exist_ok=True)
shutil.copytree(app, os.path.join(payload, 'FuelBoard.app'))
tmp = out + '.tmp'
if os.path.exists(tmp):
os.remove(tmp)
with zipfile.ZipFile(tmp, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
for root, dirs, files in os.walk(stage):
dirs.sort(); files.sort()
for name in dirs:
path = os.path.join(root, name)
zf.write(path, os.path.relpath(path, stage))
for name in files:
path = os.path.join(root, name)
zf.write(path, os.path.relpath(path, stage))
os.replace(tmp, out)
print(out)
PY
echo "==> Package sideload-safe IPA (strip Watch/)"
python3 - <<'PY'
import os, shutil, zipfile
app = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
stage = '/tmp/fuelboard-ipa-stage-sideload'
payload = os.path.join(stage, 'Payload')
out = '/tmp/ipa-serve/FuelBoard-sideload.ipa'
if os.path.exists(stage):
shutil.rmtree(stage)
os.makedirs(payload, exist_ok=True)
dst = os.path.join(payload, 'FuelBoard.app')
shutil.copytree(app, dst)
watch_dir = os.path.join(dst, 'Watch')
if os.path.exists(watch_dir):
shutil.rmtree(watch_dir)
tmp = out + '.tmp'
if os.path.exists(tmp):
os.remove(tmp)
with zipfile.ZipFile(tmp, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf:
for root, dirs, files in os.walk(stage):
dirs.sort(); files.sort()
for name in dirs:
path = os.path.join(root, name)
zf.write(path, os.path.relpath(path, stage))
for name in files:
path = os.path.join(root, name)
zf.write(path, os.path.relpath(path, stage))
os.replace(tmp, out)
print(out)
PY
echo "==> Verify artifacts"
ls -lh "$FULL_IPA" "$SIDELOAD_IPA"
python3 - <<'PY'
import os, plistlib, json
base = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
paths = {
'host': os.path.join(base, 'Info.plist'),
'watch': os.path.join(base, 'Watch', 'FuelBoardWatch Watch App.app', 'Info.plist'),
'watch_ext': os.path.join(base, 'Watch', 'FuelBoardWatch Watch App.app', 'PlugIns', 'FuelBoardWatch Watch App Extension.appex', 'Info.plist'),
}
for name, path in paths.items():
print(f'## {name} {os.path.exists(path)}')
if os.path.exists(path):
with open(path, 'rb') as f:
pl = plistlib.load(f)
print(json.dumps({k: pl.get(k) for k in ['CFBundleIdentifier','CFBundleShortVersionString','CFBundleVersion']}, indent=2))
PY
echo "==> URLs"
echo "Watch-capable IPA: http://$HOST:$PORT/FuelBoard-watch.ipa"
echo "Sideload-safe IPA: http://$HOST:$PORT/FuelBoard-sideload.ipa"
echo "Xcode log: /tmp/fuelboard-build-ipas-xcodebuild.log"