Compare commits
@@ -1,6 +1,9 @@
|
||||
build/
|
||||
Builds/
|
||||
build-*/
|
||||
build-release/
|
||||
build-sim/
|
||||
build-watch/
|
||||
.DS_Store
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# FuelBoard App Store Submission Plan
|
||||
|
||||
Updated: 2026-08-16 · Target: safe App Store submission.
|
||||
Updated: 2026-09-26 · Target: safe App Store submission.
|
||||
Companion to SECURITY.md (code security) — this file is the *review-gate*
|
||||
checklist: what must happen, who owns it, and the timeline.
|
||||
|
||||
@@ -13,10 +13,108 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
|
||||
on Apple's network see real data.
|
||||
- ~~Always-location risk~~ → **Always is requested only when the user enables
|
||||
Alerts** (ProximityMonitor escalates WhenInUse → Always on alert opt-in).
|
||||
Onboarding requests WhenInUse only.
|
||||
Onboarding requests WhenInUse only. FuelBoard does **not** declare the
|
||||
persistent real-time background location mode; optional alerts use
|
||||
CoreLocation region monitoring/significant-change behaviour instead.
|
||||
|
||||
## StoreKit sandbox: verified working configuration and caveats
|
||||
|
||||
### Verified configuration
|
||||
|
||||
The FuelBoard consumable tip products are:
|
||||
|
||||
- `com.apt.fuelboard.tip099`
|
||||
- `com.apt.fuelboard.tip299`
|
||||
- `com.apt.fuelboard.tip499`
|
||||
|
||||
The app requests these exact **Product IDs** with StoreKit 2
|
||||
`Product.products(for:)`; the UI names are display labels and are not used for
|
||||
lookup. The app bundle ID, App Store Connect app, explicit App ID, development
|
||||
team, and Sandbox Apple Account must belong to the same developer account.
|
||||
|
||||
Two test environments must remain distinct:
|
||||
|
||||
- **Local StoreKit testing:** select `/Users/apt/workspace/fuelboard/FuelBoardTips.storekit`
|
||||
in Xcode, under Product > Scheme > Edit Scheme > Run > Options. This bypasses
|
||||
App Store Connect and confirms the app's StoreKit loading and purchase code.
|
||||
- **Apple sandbox testing:** select **None** for StoreKit Configuration, then
|
||||
use Product > Run on a physical device with the development-signed build and
|
||||
a Sandbox Apple Account signed in under Settings > Developer. This exercises
|
||||
the real App Store Connect catalog.
|
||||
|
||||
The local configuration returning all three products does not prove that the
|
||||
Apple sandbox catalog is ready. Conversely, a successful local run showing `$`
|
||||
does not mean the App Store Connect price is USD: `Product.displayPrice` is
|
||||
formatted for the active StoreKit/device storefront. Local StoreKit prices are
|
||||
placeholders and do not upload to App Store Connect.
|
||||
|
||||
### Availability versus pricing
|
||||
|
||||
App Store Connect has separate controls:
|
||||
|
||||
- **Availability** controls the storefronts where the IAP is offered. The
|
||||
verified FuelBoard configuration uses **United Kingdom only**.
|
||||
- **Price schedule** may show generated prices for **175 countries and
|
||||
regions**. This is expected and does not make the IAP available in all 175
|
||||
storefronts; it is Apple's price conversion schedule.
|
||||
|
||||
The Sandbox Apple Account's App Store Country or Region must match an enabled
|
||||
IAP storefront. After changing the sandbox tester's storefront, sign out and
|
||||
back in under Settings > Developer so the storefront change activates.
|
||||
|
||||
### Important propagation/cache caveat
|
||||
|
||||
During sandbox diagnosis, `Product.products(for:)` completed successfully but
|
||||
returned zero products with the IAP availability set to UK. Temporarily
|
||||
changing one product's availability to all regions caused it to appear in the
|
||||
sandbox catalog; changing it back to UK left it working. This indicates an
|
||||
Apple-side catalog refresh or propagation/cache issue, not a change required in
|
||||
the StoreKit code. Apple documents that product metadata changes can take up to
|
||||
approximately one hour to appear in sandbox.
|
||||
|
||||
If an otherwise valid product returns zero products:
|
||||
|
||||
1. Confirm the Sandbox Apple Account belongs to the same developer team.
|
||||
2. Confirm its storefront is an enabled availability region.
|
||||
3. Sign out and back in to the sandbox account on the device.
|
||||
4. Delete/reinstall the development build and retry with StoreKit Configuration
|
||||
set to **None**.
|
||||
5. If necessary, temporarily broaden one product's availability to all
|
||||
regions, allow propagation, verify the product, then restore the intended
|
||||
availability and allow propagation again.
|
||||
|
||||
Record whether the diagnostic result is **request failed**, **completed with
|
||||
zero products**, or **a partial product set**. These are different failure
|
||||
classes. Do not add fallback prices or change product lookup to display names.
|
||||
|
||||
Sandbox testing does not require prior App Review approval of the IAP. It does
|
||||
require active developer membership, an active Paid Applications Agreement,
|
||||
complete banking and tax information, valid product metadata and pricing, the
|
||||
correct app/team association, and a matching Sandbox Apple Account.
|
||||
|
||||
## Hard blockers (must happen before ANY submission)
|
||||
|
||||
0. **[USER] Paid Applications Agreement in effect** — REQUIRED for paid IAP
|
||||
configuration and sandbox testing. App Store Connect →
|
||||
Business > Paid Applications Agreement → View and Agree. **This is the most
|
||||
common cause of a 2.1(b) "unable to purchase item / error message" rejection
|
||||
— without it every sandbox purchase fails.** (REJECTED once for this:
|
||||
review date 2026-09-25, version 1.0 (21), iPad Air 11″ iPadOS 27.)
|
||||
**Status 2026-09-25: agreement clicked Agree but shows "Pending user info" —
|
||||
this is NOT in effect yet.** Complete the Banking (account + sort code/IBAN)
|
||||
and Tax (W-8/W-9/UK tax ref, e.g. sole-trader UTR) sections under Business >
|
||||
Payments & Tax until the agreement shows "Active" and every tab is green.
|
||||
Only then do sandbox IAPs start working.
|
||||
0. **[USER] Tip IAPs configured and available to the sandbox** — the three
|
||||
consumables may exist but must ALSO have complete metadata, a current price,
|
||||
and an enabled storefront availability. Prior App Review approval or
|
||||
`Cleared for Sale` status is **not required for sandbox product discovery**.
|
||||
They must be backed by the **In-App Purchase capability on the App ID**
|
||||
`com.apt.fuelboard` (developer portal → Identifiers → App ID capability).
|
||||
3-tier tip code is already correct (retry lookup `7cb23e0`); do NOT churn
|
||||
SettingsView.swift. Test product discovery and purchase with a Sandbox
|
||||
Apple Account first: Settings → Support FuelBoard.
|
||||
|
||||
1. **[USER] Apple Developer Program membership** — the app has never been
|
||||
signed (all builds are `CODE_SIGNING_ALLOWED=NO` + ad-hoc re-sign for
|
||||
SideStore). Requires a paid membership ($99/yr, up to 48 h to approve) and
|
||||
@@ -25,31 +123,32 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
|
||||
with the **App Group `group.com.apt.fuelboard`** capability AND the
|
||||
**In-App Purchase** capability enabled (the widget + app-group cache
|
||||
depend on the group; the tip jar needs IAP).
|
||||
3. ~~[ME] `PrivacyInfo.xcprivacy`~~ — **DONE 2026-08-16** (app + widget
|
||||
manifests added; UserDefaults `1C8F.1`, Location + Diagnostics declared).
|
||||
3. ~~[ME] `PrivacyInfo.xcprivacy`~~ — **DONE** (app, widget, watch app, and
|
||||
watch wrapper manifests added; UserDefaults `1C8F.1`; app declares Location).
|
||||
4. **[ME] Real Distribution signing** — Distribution certificate + App Store
|
||||
provisioning profile (or Xcode automatic signing once the account is in
|
||||
Xcode); then `xcodebuild archive` (no more `CODE_SIGNING_ALLOWED=NO`).
|
||||
5. **[USER] Privacy policy URL** in App Store Connect — mandatory; the app
|
||||
collects location. A one-page GitHub Pages privacy policy is fine.
|
||||
collects location. Policy text is now in `PRIVACY.md`; publish it via the
|
||||
FuelBoard marketing site or GitHub Pages and paste that public URL into App
|
||||
Store Connect.
|
||||
|
||||
## Required tasks — owner [ME] (technical, do after account exists)
|
||||
|
||||
- [x] **PrivacyInfo.xcprivacy** — app + widget manifests added (2026-08-16):
|
||||
UserDefaults reason `1C8F.1`; app also declares Location (linked, not
|
||||
tracking) + Diagnostics (not linked, not tracking — the dev beacon)
|
||||
- [x] **PrivacyInfo.xcprivacy** — app, widget, watch app, and watch wrapper
|
||||
manifests added: UserDefaults reason `1C8F.1`; app also declares Location
|
||||
(linked, not tracking). Diagnostic network beacon removed with LAN relay.
|
||||
- [ ] Distribution archive + TestFlight upload (needs Apple account)
|
||||
- [x] **Version bump** → `1.0.1` (build 2), app + widget plists
|
||||
- [x] **Version/build aligned** → `1.0.0` (build 23), app + widget + watch targets
|
||||
- [x] **OGL attribution** — Settings → Data source footer now reads "Prices
|
||||
use the UK Fuel Finder dataset (Open Government Licence) and refresh
|
||||
up to twice a day." (2.3.1 hygiene)
|
||||
- [x] **Out-of-UK empty state** — "No X stations within N miles" + UK-only
|
||||
explainer + "Browse all UK stations" button (switches to Closest)
|
||||
- [ ] ~~Gate dev surface behind #if DEBUG~~ **DECLINED — kept in Release on
|
||||
purpose**: the user's home-testing installs the Release IPA and toggles
|
||||
`fuelboard.relayFallback` via Settings → Debug (revealed only by tapping
|
||||
About → Version ×5 — invisible to reviewers; no credentials involved).
|
||||
Gating it out of Release would break that workflow for zero review gain.
|
||||
- [x] **Remove LAN relay fallback entirely** — no private IP, Local Network
|
||||
permission string, relay toggle, or diagnostic network beacon remains in
|
||||
the app/widget/watch binary. Hidden Debug still exists for local notification
|
||||
and widget diagnostics only.
|
||||
- [ ] Final pass: `swift test` green (95 tests), archive smoke test on device
|
||||
- [ ] ~~Capture screenshots from simulator~~ **RAW CAPTURES DONE (2026-08-16)** —
|
||||
native 6.9″ 1320×2868 PNGs of all four tabs (Stations, Favourites,
|
||||
@@ -75,8 +174,7 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
|
||||
category (Utilities), pricing (Free), availability (UK first?)
|
||||
- [ ] Description, keywords, support URL, marketing URL, privacy policy URL
|
||||
- [ ] Privacy nutrition labels questionnaire (location: yes, linked to user
|
||||
for alerts/nearby; diagnostics: yes — the widget-diag beacon, not
|
||||
linked, not used for tracking; no ATT prompt needed)
|
||||
for alerts/nearby; no tracking; no ATT prompt needed)
|
||||
- [ ] Age rating questionnaire (4+; no objectionable content)
|
||||
- [ ] Export compliance: HTTPS-only + keychain → standard encryption
|
||||
exemption (5A992a), no documentation needed
|
||||
@@ -90,19 +188,23 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
|
||||
> Prices are downloaded from the internet up to twice a day; the app filters
|
||||
> 5/10/15-mile radii on-device and works offline from a bundled snapshot.
|
||||
> Location is used to (a) rank stations by distance and (b) power the
|
||||
> optional Alerts feature: when the user enables alerts, the app geofences
|
||||
> the cheapest station in their radius and sends a notification when they
|
||||
> approach it. Always-location is requested only when the user enables
|
||||
> alerts. The app also provides a home-screen widget showing the current
|
||||
> cheapest station nearby, and a price-history Trends chart for starred
|
||||
> stations. All data is UK-wide (Fuel Finder covers England, Scotland, Wales
|
||||
> and Northern Ireland); users outside the UK see an empty state explaining
|
||||
> this.
|
||||
> optional Alerts feature: when the user enables alerts, the app registers
|
||||
> CoreLocation regions around the cheapest station in their radius and sends
|
||||
> a notification when they approach it. FuelBoard does not use persistent
|
||||
> real-time background location tracking; it relies on iOS region monitoring
|
||||
> and significant-change behaviour for optional alerts. Always-location is
|
||||
> requested only when the user enables alerts. The app also provides a
|
||||
> home-screen widget showing the current cheapest station nearby, and a
|
||||
> price-history Trends chart for starred stations. All data is UK-wide (Fuel
|
||||
> Finder covers England, Scotland, Wales and Northern Ireland); users outside
|
||||
> the UK see an empty state explaining this.
|
||||
|
||||
## Review risks (know before submitting)
|
||||
|
||||
- **Always-location (5.1.1)** — the #1 question. The feature is visible
|
||||
(Alerts tab) and opt-in; the review notes above say so explicitly.
|
||||
- **Always-location (5.1.1 / 2.5.4)** — the feature is visible (Alerts tab)
|
||||
and opt-in; the app does not declare persistent background location mode,
|
||||
and the review notes describe region monitoring/significant-change rather
|
||||
than continuous tracking.
|
||||
- **Reviewer outside the UK** — empty radius; mitigated by the empty-state
|
||||
task above + review notes.
|
||||
- **"Cheapest" claims (2.3.1)** — mitigated by the OGL/footnote task.
|
||||
@@ -110,8 +212,8 @@ The two 2026-08-11 blockers are RESOLVED by P0 (live chain):
|
||||
glance. It is declared in the plist already.
|
||||
- **Brand logos (Shell, BP, Tesco…)** — Simple Icons (CC0) + Wikimedia,
|
||||
nominative use (cf. GasBuddy); low risk, keep sources documented.
|
||||
- **GitHub raw as data source** — public data, no credentials; bundled dump
|
||||
covers any regional GitHub unreachability.
|
||||
- **GitHub raw as data source** — public HTTPS data, no credentials or Local
|
||||
Network permission; bundled dump covers any regional GitHub unreachability.
|
||||
|
||||
## Timeline (best case)
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 724 KiB |
|
After Width: | Height: | Size: 686 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 280 KiB |
|
After Width: | Height: | Size: 349 KiB |
@@ -4,7 +4,7 @@ FuelBoard — UK-wide petrol-station price widget + app.
|
||||
GitHub mirror (`aptonline/fuelboard-data`, 2×/day 10:00+16:00) holds the
|
||||
full-UK dump (~8,022 stations, all prices); app fetches it once per 12 h and
|
||||
filters/sorts on-device, with a bundled snapshot as the no-network last resort.
|
||||
LAN relay is dev-only (hidden `fuelboard.relayFallback` flag).
|
||||
LAN relay fallback has been removed from the app binary; the public GitHub mirror is the only live network data source.
|
||||
|
||||
Priority guide: P0 = next build, P1 = soon, P2 = later, P3 = maybe/idea.
|
||||
Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
||||
@@ -106,6 +106,19 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
||||
scene (CPListTemplate) for navigation deep-links.
|
||||
- [ ] **Alert region tuning** — make alert trigger radius + dedup window
|
||||
user-configurable (currently fixed 300 m trigger / 1 per station per hour).
|
||||
- [ ] **Price-drop alerts: threshold control + Check now** — keep the current
|
||||
cache-first / 12 h refresh policy, but add a per-alert sensitivity control
|
||||
for favourite price-drop alerts instead of a fetch-frequency slider. Scope:
|
||||
`Minimum change` picker (e.g. 0.5p / 1.0p / 2.0p), plus a `Check now`
|
||||
action in the Alerts tab that forces a fetch and immediately re-evaluates the
|
||||
cheapest-favourite alert. Copy should set expectation honestly: standard
|
||||
mode checks when FuelBoard refreshes prices (up to twice daily).
|
||||
- [ ] **Price-drop alerts: optional frequent-check mode** — if the app ever
|
||||
relaxes the current "twice daily max" data policy, expose this as a clearly
|
||||
separate mode rather than a freeform frequency slider. Example framing:
|
||||
`Standard` (current behaviour) vs `Frequent` (best-effort extra checks),
|
||||
with conservative caps (e.g. every 4–6 h, not hourly) and copy that does not
|
||||
promise exact timing under iOS background scheduling.
|
||||
- [ ] **Pull-to-refresh spinner state** — surface refresh in-flight state
|
||||
(currently `refreshable` fires but no visible progress in the row list).
|
||||
- [x] **Offline first-run** — bundled REAL 8,022-station dump (`FuelBoardDump`,
|
||||
@@ -131,13 +144,50 @@ 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
|
||||
price drops below a user threshold.
|
||||
- [ ] **Price-drop alerts: mode = Cheapest favourite / All favourites** — keep
|
||||
`Cheapest favourite` as the default low-noise mode, but explore an optional
|
||||
`All favourites` mode for one fuel. Guardrails if built: threshold required,
|
||||
per-station dedupe based on last-alerted price (not just time), and combine
|
||||
multiple drops from one refresh into a single summary notification rather
|
||||
than one alert per station.
|
||||
- [ ] **Share sheet** — share "Cheapest Unleaded near me" as text/image card.
|
||||
- [ ] **Watch app** — glanceable cheapest-price complication (WatchKit + shared
|
||||
app-group store; watch target would need to be added to the project).
|
||||
- [ ] **Watch app v1 — favourites + Check now** — small Apple Watch companion,
|
||||
not a phone-app clone. Favourites-first home screen: fuel picker, `Cheapest
|
||||
favourite` summary card, top few favourites for that fuel, last-updated time,
|
||||
and a prominent `Check now` action that forces a refresh + re-evaluates
|
||||
favourite prices. Keep the watch UX pull-based and glanceable; avoid implying
|
||||
high-frequency background refresh. Shared-store architecture preferred over a
|
||||
second watch-only data model.
|
||||
- [ ] **Watch complications / Smart Stack** — complication/widget surfaces for
|
||||
the same watch concept: show the cheapest favourite price, fuel, and short
|
||||
station name at a glance. Keep copy minimal and honest about freshness.
|
||||
- [ ] **Watch app v2 — favourite detail + handoff** — tap a favourite row for a
|
||||
detail screen (station, brand, price, last updated) with actions like
|
||||
`Directions` and `Open on iPhone`. Reuse existing maps handoff patterns where
|
||||
possible instead of inventing watch-only navigation logic.
|
||||
- [ ] **App Clip** — 10 MB budget for "find cheapest fuel nearby" without install.
|
||||
- [ ] **Widget picker: dual-unit distance labels** — the Edit-Widget Distance row
|
||||
label is cached by the system at sheet-render time and only re-resolves on
|
||||
@@ -161,15 +211,12 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
||||
deploy key, 180-day retention) + history read side (Trends, P1) + **live chain
|
||||
(`feature/p0-live-chain`)**: `MirrorFuelProvider` fetches CURRENT prices from
|
||||
raw.githubusercontent (latest.json → history/<day>.json, app-group day-cache) →
|
||||
**GitHub FIRST everywhere** (alert checks + widget too — the relay is
|
||||
unreachable from any consumer phone and attempting it would fire the Local
|
||||
Network prompt); LAN relay joins ONLY behind the hidden dev flag
|
||||
`fuelboard.relayFallback` (Settings → Debug, off by default); bundled REAL
|
||||
dump (`FuelBoardDump` dataset, refreshed by `scripts/refresh_bundled_dump.sh`)
|
||||
is the app's no-network last resort. `FuelBeacon` fire-and-forget X-Client app
|
||||
ping fires only with the dev flag on. **Local Network permission removed from
|
||||
onboarding entirely** — data page is informational, replay on cellular can no
|
||||
longer hang. 95 tests.
|
||||
**GitHub mirror only everywhere** (alert checks + widget too); no LAN relay
|
||||
fallback, private IP, Local Network permission, or diagnostic network beacon
|
||||
ships in the app binary. Bundled REAL dump (`FuelBoardDump` dataset, refreshed
|
||||
by `scripts/refresh_bundled_dump.sh`) is the app's no-network last resort.
|
||||
Local Network permission removed entirely — data page is informational, replay
|
||||
on cellular can no longer hang. 107+ tests.
|
||||
- [x] Switch dataset to govuk API data — official Fuel Finder OAuth API via
|
||||
relay-api :8789 (source: api, ~8,012 stations); app baseURL → :8789; credentials
|
||||
live in relay `.env` only, never in the IPA
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.1</string>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -30,20 +30,22 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2</string>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>FuelBoard can optionally connect to the FuelBoard Relay on your local network for faster alerts during development.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>FuelBoard uses Always location to alert you when you approach the cheapest station nearby.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>FuelBoard uses your location to find the cheapest nearby petrol stations.</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
<string>com.apt.fuelboard.smart-refresh</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>location</string>
|
||||
<string>fetch</string>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
// !$*UTF8*$!
|
||||
// FuelBoard — hand-crafted project (objectVersion 77, synchronized folders).
|
||||
// Targets: FuelBoard (app) + FuelBoardWidgets (widget extension, embedded).
|
||||
// Shared/ is a synchronized root group included in BOTH targets.
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
@@ -10,10 +7,37 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
ADCE5B5A3033A32C008C131C /* FuelBoardWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
BB0000000000000000000001 /* FuelBoardWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* FuelBoardWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
FBA000000000000000000001 /* DataRefreshMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000101 /* DataRefreshMode.swift */; };
|
||||
FBA000000000000000000002 /* FuelHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000102 /* FuelHistory.swift */; };
|
||||
FBA000000000000000000003 /* FuelPriceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000103 /* FuelPriceProvider.swift */; };
|
||||
FBA000000000000000000004 /* FuelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000104 /* FuelStore.swift */; };
|
||||
FBA000000000000000000005 /* MirrorFuelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000105 /* MirrorFuelProvider.swift */; };
|
||||
FBA000000000000000000006 /* SiriCheapestLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000106 /* SiriCheapestLookup.swift */; };
|
||||
FBA000000000000000000007 /* WidgetLocationFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000107 /* WidgetLocationFetcher.swift */; };
|
||||
FBA000000000000000000008 /* FuelBoardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000108 /* FuelBoardTests.swift */; };
|
||||
FBA000000000000000000009 /* FuelHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000109 /* FuelHistoryTests.swift */; };
|
||||
FBA00000000000000000000A /* LiveChainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA00000000000000000010A /* LiveChainTests.swift */; };
|
||||
FBA00000000000000000000B /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FBA00000000000000000010B /* XCTest.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
ADCE5B643033A32D008C131C /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = BB0000000000000000000004 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = ADCE5B4E3033A329008C131C;
|
||||
remoteInfo = "FuelBoardWatch Watch App Extension";
|
||||
};
|
||||
ADCE5B6F3033A32F008C131C /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = BB0000000000000000000004 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = ADCE5B673033A32D008C131C;
|
||||
remoteInfo = "FuelBoardWatch Watch App";
|
||||
};
|
||||
BB0000000000000000000003 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = BB0000000000000000000004 /* Project object */;
|
||||
@@ -24,6 +48,28 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
ADCE5B5B3033A32C008C131C /* Embed Watch Content */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
|
||||
dstSubfolderSpec = 16;
|
||||
files = (
|
||||
ADCE5B5A3033A32C008C131C /* FuelBoardWatch Watch App.app in Embed Watch Content */,
|
||||
);
|
||||
name = "Embed Watch Content";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
ADCE5B653033A32D008C131C /* Embed App Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */,
|
||||
);
|
||||
name = "Embed App Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
BB0000000000000000000006 /* Embed App Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -38,11 +84,36 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
AD22FCFF30682512009859D4 /* FuelBoardTips.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = FuelBoardTips.storekit; sourceTree = "<group>"; };
|
||||
AD64FC4C30739A4B008F27A9 /* FuelBoardSharedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FuelBoardSharedTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FuelBoardWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "FuelBoardWatch Watch App Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
BB0000000000000000000002 /* FuelBoardWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FuelBoardWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
BB0000000000000000000007 /* FuelBoard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FuelBoard.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FBA000000000000000000101 /* DataRefreshMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataRefreshMode.swift; sourceTree = "<group>"; };
|
||||
FBA000000000000000000102 /* FuelHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelHistory.swift; sourceTree = "<group>"; };
|
||||
FBA000000000000000000103 /* FuelPriceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelPriceProvider.swift; sourceTree = "<group>"; };
|
||||
FBA000000000000000000104 /* FuelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelStore.swift; sourceTree = "<group>"; };
|
||||
FBA000000000000000000105 /* MirrorFuelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MirrorFuelProvider.swift; sourceTree = "<group>"; };
|
||||
FBA000000000000000000106 /* SiriCheapestLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiriCheapestLookup.swift; sourceTree = "<group>"; };
|
||||
FBA000000000000000000107 /* WidgetLocationFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetLocationFetcher.swift; sourceTree = "<group>"; };
|
||||
FBA000000000000000000108 /* FuelBoardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelBoardTests.swift; sourceTree = "<group>"; };
|
||||
FBA000000000000000000109 /* FuelHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelHistoryTests.swift; sourceTree = "<group>"; };
|
||||
FBA00000000000000000010A /* LiveChainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveChainTests.swift; sourceTree = "<group>"; };
|
||||
FBA00000000000000000010B /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = System/Library/Frameworks/XCTest.framework; sourceTree = SDKROOT; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = "FuelBoardWatch Watch App";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = "FuelBoardWatch Wrapper";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BB0000000000000000000008 /* FuelBoard */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = FuelBoard;
|
||||
@@ -61,6 +132,20 @@
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
ADCE5B4C3033A329008C131C /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
ADCE5B6D3033A32E008C131C /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
BB000000000000000000000B /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -75,16 +160,37 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
FBA000000000000000000302 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
FBA00000000000000000000B /* XCTest.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
AD64FC4B30739A4B008F27A9 /* Recovered References */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FBA00000000000000000010B /* XCTest.framework */,
|
||||
);
|
||||
name = "Recovered References";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BB000000000000000000000D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FBA000000000000000000200 /* FuelBoardTests */,
|
||||
AD22FCFF30682512009859D4 /* FuelBoardTips.storekit */,
|
||||
BB0000000000000000000008 /* FuelBoard */,
|
||||
BB0000000000000000000009 /* FuelBoardWidgets */,
|
||||
BB000000000000000000000A /* Shared */,
|
||||
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */,
|
||||
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
|
||||
BB000000000000000000000E /* Products */,
|
||||
AD64FC4B30739A4B008F27A9 /* Recovered References */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -93,37 +199,95 @@
|
||||
children = (
|
||||
BB0000000000000000000007 /* FuelBoard.app */,
|
||||
BB0000000000000000000002 /* FuelBoardWidgets.appex */,
|
||||
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */,
|
||||
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */,
|
||||
AD64FC4C30739A4B008F27A9 /* FuelBoardSharedTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FBA000000000000000000200 /* FuelBoardTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FBA000000000000000000201 /* Sources */,
|
||||
FBA000000000000000000202 /* Tests */,
|
||||
);
|
||||
path = FuelBoardTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FBA000000000000000000201 /* Sources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FBA000000000000000000101 /* DataRefreshMode.swift */,
|
||||
FBA000000000000000000102 /* FuelHistory.swift */,
|
||||
FBA000000000000000000103 /* FuelPriceProvider.swift */,
|
||||
FBA000000000000000000104 /* FuelStore.swift */,
|
||||
FBA000000000000000000105 /* MirrorFuelProvider.swift */,
|
||||
FBA000000000000000000106 /* SiriCheapestLookup.swift */,
|
||||
FBA000000000000000000107 /* WidgetLocationFetcher.swift */,
|
||||
);
|
||||
name = Sources;
|
||||
path = Sources/FuelBoardShared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FBA000000000000000000202 /* Tests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FBA000000000000000000108 /* FuelBoardTests.swift */,
|
||||
FBA000000000000000000109 /* FuelHistoryTests.swift */,
|
||||
FBA00000000000000000010A /* LiveChainTests.swift */,
|
||||
);
|
||||
name = Tests;
|
||||
path = Tests/FuelBoardSharedTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
BB000000000000000000000F /* FuelBoard */ = {
|
||||
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = BB0000000000000000000010 /* Build configuration list for PBXNativeTarget "FuelBoard" */;
|
||||
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App Extension" */;
|
||||
buildPhases = (
|
||||
BB0000000000000000000011 /* Sources */,
|
||||
BB000000000000000000000B /* Frameworks */,
|
||||
BB0000000000000000000012 /* Resources */,
|
||||
BB0000000000000000000006 /* Embed App Extensions */,
|
||||
ADCE5B4B3033A329008C131C /* Sources */,
|
||||
ADCE5B4C3033A329008C131C /* Frameworks */,
|
||||
ADCE5B4D3033A329008C131C /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
BB0000000000000000000013 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
BB0000000000000000000008 /* FuelBoard */,
|
||||
BB000000000000000000000A /* Shared */,
|
||||
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
|
||||
);
|
||||
name = FuelBoard;
|
||||
name = "FuelBoardWatch Watch App Extension";
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = FuelBoard;
|
||||
productReference = BB0000000000000000000007 /* FuelBoard.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
productName = "FuelBoardWatch Watch App Extension";
|
||||
productReference = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */;
|
||||
productType = "com.apple.product-type.watchkit2-extension";
|
||||
};
|
||||
ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = ADCE5B6A3033A32D008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */;
|
||||
buildPhases = (
|
||||
ADCE5B6E3033A32E008C131C /* Resources */,
|
||||
ADCE5B653033A32D008C131C /* Embed App Extensions */,
|
||||
ADCE5B6D3033A32E008C131C /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
ADCE5B683033A32D008C131C /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */,
|
||||
);
|
||||
name = "FuelBoardWatch Watch App";
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = "FuelBoardWatch Watch App";
|
||||
productReference = ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */;
|
||||
productType = "com.apple.product-type.application.watchapp2";
|
||||
};
|
||||
BB0000000000000000000005 /* FuelBoardWidgets */ = {
|
||||
isa = PBXNativeTarget;
|
||||
@@ -148,6 +312,50 @@
|
||||
productReference = BB0000000000000000000002 /* FuelBoardWidgets.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
BB000000000000000000000F /* FuelBoard */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = BB0000000000000000000010 /* Build configuration list for PBXNativeTarget "FuelBoard" */;
|
||||
buildPhases = (
|
||||
BB0000000000000000000011 /* Sources */,
|
||||
BB000000000000000000000B /* Frameworks */,
|
||||
BB0000000000000000000012 /* Resources */,
|
||||
ADCE5B5B3033A32C008C131C /* Embed Watch Content */,
|
||||
BB0000000000000000000006 /* Embed App Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
ADCE5B703033A32F008C131C /* PBXTargetDependency */,
|
||||
BB0000000000000000000013 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
BB0000000000000000000008 /* FuelBoard */,
|
||||
BB000000000000000000000A /* Shared */,
|
||||
);
|
||||
name = FuelBoard;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = FuelBoard;
|
||||
productReference = BB0000000000000000000007 /* FuelBoard.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
FBA000000000000000000300 /* FuelBoardSharedTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = FBA000000000000000000400 /* Build configuration list for PBXNativeTarget "FuelBoardSharedTests" */;
|
||||
buildPhases = (
|
||||
FBA000000000000000000301 /* Sources */,
|
||||
FBA000000000000000000302 /* Frameworks */,
|
||||
FBA000000000000000000303 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = FuelBoardSharedTests;
|
||||
productName = FuelBoardSharedTests;
|
||||
productReference = AD64FC4C30739A4B008F27A9 /* FuelBoardSharedTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -155,15 +363,23 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1600;
|
||||
LastSwiftUpdateCheck = 2630;
|
||||
LastUpgradeCheck = 1600;
|
||||
TargetAttributes = {
|
||||
BB000000000000000000000F = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
ADCE5B4E3033A329008C131C = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
};
|
||||
BB0000000000000000000005 = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
};
|
||||
BB000000000000000000000F = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
SystemCapabilities = {
|
||||
com.apple.InAppPurchase = {
|
||||
enabled = 1;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */;
|
||||
@@ -182,11 +398,28 @@
|
||||
targets = (
|
||||
BB000000000000000000000F /* FuelBoard */,
|
||||
BB0000000000000000000005 /* FuelBoardWidgets */,
|
||||
ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */,
|
||||
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */,
|
||||
FBA000000000000000000300 /* FuelBoardSharedTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
ADCE5B4D3033A329008C131C /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
ADCE5B6E3033A32E008C131C /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
BB0000000000000000000012 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -201,9 +434,23 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
FBA000000000000000000303 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
ADCE5B4B3033A329008C131C /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
BB0000000000000000000011 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -218,9 +465,36 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
FBA000000000000000000301 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
FBA000000000000000000001 /* DataRefreshMode.swift in Sources */,
|
||||
FBA000000000000000000002 /* FuelHistory.swift in Sources */,
|
||||
FBA000000000000000000003 /* FuelPriceProvider.swift in Sources */,
|
||||
FBA000000000000000000004 /* FuelStore.swift in Sources */,
|
||||
FBA000000000000000000005 /* MirrorFuelProvider.swift in Sources */,
|
||||
FBA000000000000000000006 /* SiriCheapestLookup.swift in Sources */,
|
||||
FBA000000000000000000007 /* WidgetLocationFetcher.swift in Sources */,
|
||||
FBA000000000000000000008 /* FuelBoardTests.swift in Sources */,
|
||||
FBA000000000000000000009 /* FuelHistoryTests.swift in Sources */,
|
||||
FBA00000000000000000000A /* LiveChainTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
ADCE5B683033A32D008C131C /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */;
|
||||
targetProxy = ADCE5B643033A32D008C131C /* PBXContainerItemProxy */;
|
||||
};
|
||||
ADCE5B703033A32F008C131C /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */;
|
||||
targetProxy = ADCE5B6F3033A32F008C131C /* PBXContainerItemProxy */;
|
||||
};
|
||||
BB0000000000000000000013 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = BB0000000000000000000005 /* FuelBoardWidgets */;
|
||||
@@ -229,6 +503,266 @@
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
ADCE5B573033A32B008C131C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/WatchExtension-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 26.2;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
ADCE5B583033A32B008C131C /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/WatchExtension-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 26.2;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
ADCE5B6B3033A32D008C131C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/WatchWrapper-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 26.2;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
ADCE5B6C3033A32D008C131C /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = "Config/WatchWrapper-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 26.2;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
BB0000000000000000000018 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -290,17 +824,20 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Config/App-Info.plist;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
@@ -312,17 +849,20 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Config/App-Info.plist;
|
||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
@@ -335,15 +875,16 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Config/Widget-Info.plist;
|
||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -359,15 +900,16 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = Config/Widget-Info.plist;
|
||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -377,14 +919,69 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
FBA000000000000000000401 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = FuelBoardSharedTests;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.sharedtests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
FBA000000000000000000402 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = FuelBoardSharedTests;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.sharedtests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */ = {
|
||||
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App Extension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
BB0000000000000000000018 /* Debug */,
|
||||
BB0000000000000000000019 /* Release */,
|
||||
ADCE5B573033A32B008C131C /* Debug */,
|
||||
ADCE5B583033A32B008C131C /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
ADCE5B6A3033A32D008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
ADCE5B6B3033A32D008C131C /* Debug */,
|
||||
ADCE5B6C3033A32D008C131C /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
@@ -407,6 +1004,24 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
BB0000000000000000000017 /* Build configuration list for PBXProject "FuelBoard" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
BB0000000000000000000018 /* Debug */,
|
||||
BB0000000000000000000019 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
FBA000000000000000000400 /* Build configuration list for PBXNativeTarget "FuelBoardSharedTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
FBA000000000000000000401 /* Debug */,
|
||||
FBA000000000000000000402 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = BB0000000000000000000004 /* Project object */;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2600"
|
||||
version = "1.8">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BB000000000000000000000F"
|
||||
BuildableName = "FuelBoard.app"
|
||||
BlueprintName = "FuelBoard"
|
||||
ReferencedContainer = "container:FuelBoard.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "FBA000000000000000000300"
|
||||
BuildableName = "FuelBoardSharedTests.xctest"
|
||||
BlueprintName = "FuelBoardSharedTests"
|
||||
ReferencedContainer = "container:FuelBoard.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../../FuelBoardTips.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
language = "en"
|
||||
region = "GB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersion = "1.0"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BB000000000000000000000F"
|
||||
BuildableName = "FuelBoard.app"
|
||||
BlueprintName = "FuelBoard"
|
||||
ReferencedContainer = "container:FuelBoard.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersion = "1.0"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BB000000000000000000000F"
|
||||
BuildableName = "FuelBoard.app"
|
||||
BlueprintName = "FuelBoard"
|
||||
ReferencedContainer = "container:FuelBoard.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -12,6 +12,14 @@ struct AlertsView: View {
|
||||
@Binding var enabled: Bool
|
||||
@Binding var radius: Double // stored in km (monitor + storage)
|
||||
@Binding var fuel: FuelType // the fuel alerts monitor for
|
||||
@Binding var alertMode: AlertMode
|
||||
/// Read-only here: the threshold is edited in Settings → Fuel Savings, where
|
||||
/// the fill size and MPG that decide the saving live. Read back so the
|
||||
/// alert's own rule is visible without leaving the tab.
|
||||
let bestValueThresholdGBP: Double
|
||||
let bestValueConfigured: Bool
|
||||
@Binding var favouriteDropEnabled: Bool
|
||||
@Binding var favouriteDropFuel: FuelType
|
||||
/// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
|
||||
@Binding var followsSearch: Bool
|
||||
/// Whether the "cheapest nearby" Live Activity is shown on the Lock Screen
|
||||
@@ -30,12 +38,13 @@ struct AlertsView: View {
|
||||
let distanceUnit: DistanceUnit
|
||||
let monitoredCount: Int
|
||||
let lastAlert: String?
|
||||
let onCheckFavouriteDropNow: () -> Void
|
||||
|
||||
/// The Stations-tab search distance shown in the user's unit, e.g.
|
||||
/// "5 miles" or "8 km" — for the "Follow search" rows.
|
||||
private var searchInUnitLabel: String {
|
||||
let shown = distanceUnit.displayMiles(stationLimit) // stationLimit is always miles
|
||||
return "\(shown) \(distanceUnit.label(for: Double(shown)))"
|
||||
return String(format: String(localized: "%lld %@", comment: "Distance label"), shown, distanceUnit.label(for: Double(shown)))
|
||||
}
|
||||
|
||||
/// A miles value (5/10/15) shown truthfully in the user's unit, e.g.
|
||||
@@ -43,7 +52,7 @@ struct AlertsView: View {
|
||||
/// what the option really means.
|
||||
private func distanceLabel(forMiles miles: Int) -> String {
|
||||
let shown = distanceUnit.displayMiles(miles)
|
||||
return "\(shown) \(distanceUnit.label(for: Double(shown)))"
|
||||
return String(format: String(localized: "%lld %@", comment: "Distance label"), shown, distanceUnit.label(for: Double(shown)))
|
||||
}
|
||||
|
||||
/// Situational guidance for the currently-selected alert radius, shown
|
||||
@@ -54,23 +63,23 @@ struct AlertsView: View {
|
||||
private var alertRadiusGuidance: String {
|
||||
switch alertChoice.wrappedValue {
|
||||
case .followSearch:
|
||||
return "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing)."
|
||||
return String(localized: "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing).", comment: "Alert radius guidance for following search distance")
|
||||
case .fixed(let value):
|
||||
let optionLabel = "\(value) \(distanceUnit.label(for: Double(value)))"
|
||||
let optionLabel = String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))
|
||||
let body: String
|
||||
switch value {
|
||||
case 1:
|
||||
body = "Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips."
|
||||
body = String(localized: "Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips.", comment: "Alert radius guidance")
|
||||
case 2:
|
||||
body = "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town."
|
||||
body = String(localized: "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town.", comment: "Alert radius guidance")
|
||||
case 3:
|
||||
body = "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns."
|
||||
body = String(localized: "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns.", comment: "Alert radius guidance")
|
||||
case 5:
|
||||
body = "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle."
|
||||
body = String(localized: "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle.", comment: "Alert radius guidance")
|
||||
default:
|
||||
body = "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires."
|
||||
body = String(localized: "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires.", comment: "Alert radius guidance")
|
||||
}
|
||||
return "\(optionLabel) — \(body)"
|
||||
return String(format: String(localized: "%@ — %@", comment: "Alert radius guidance with option label"), optionLabel, body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,59 +140,96 @@ struct AlertsView: View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Toggle("Cheapest-station alerts", isOn: $enabled)
|
||||
Picker("Fuel", selection: $fuel) {
|
||||
Toggle(String(localized: "Cheapest favourite", comment: "Favourite price-drop alert toggle"), isOn: $favouriteDropEnabled)
|
||||
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $favouriteDropFuel) {
|
||||
ForEach(FuelType.allCases) { fuel in
|
||||
Text(fuel.displayName).tag(fuel)
|
||||
}
|
||||
}
|
||||
Picker("Radius", selection: alertChoice) {
|
||||
Text("Follow search (\(searchInUnitLabel))").tag(AlertRadiusChoice.followSearch)
|
||||
Button(String(localized: "Check now", comment: "Manual favourite price-drop check button"), action: onCheckFavouriteDropNow)
|
||||
.disabled(!favouriteDropEnabled)
|
||||
} header: {
|
||||
Text(String(localized: "Price-drop alerts", comment: "Favourite price-drop alerts section title"))
|
||||
} footer: {
|
||||
Text(String(localized: "Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise.", comment: "Favourite price-drop alerts section footer"))
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
|
||||
Picker(String(localized: "Alert type", comment: "Proximity alert type picker title"), selection: $alertMode) {
|
||||
ForEach(AlertMode.allCases.filter { mode in mode != .bestValue || bestValueConfigured }) { mode in
|
||||
Text(mode.displayName).tag(mode)
|
||||
}
|
||||
}
|
||||
if !bestValueConfigured {
|
||||
Text(String(localized: "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.", comment: "Best Value alert unavailable hint"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $fuel) {
|
||||
ForEach(FuelType.allCases) { fuel in
|
||||
Text(fuel.displayName).tag(fuel)
|
||||
}
|
||||
}
|
||||
Picker(String(localized: "Radius", comment: "Alert radius picker title"), selection: alertChoice) {
|
||||
Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(AlertRadiusChoice.followSearch)
|
||||
ForEach(FuelStore.alertRadiusOptions, id: \.self) { value in
|
||||
Text("\(value) \(distanceUnit.label(for: Double(value)))").tag(AlertRadiusChoice.fixed(value))
|
||||
Text(String(format: String(localized: "%lld %@", comment: "Distance label"), value, distanceUnit.label(for: Double(value)))).tag(AlertRadiusChoice.fixed(value))
|
||||
}
|
||||
}
|
||||
Text(alertRadiusGuidance)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if alertMode == .bestValue {
|
||||
// The threshold is edited in Settings → Fuel Savings: it
|
||||
// labels station rows BEST VALUE as well as gating this
|
||||
// alert, so it lives with the fill size and MPG that
|
||||
// produce the saving. Read back here, not offered twice.
|
||||
Text(String(format: String(localized: "Minimum saving %@ — change in Settings, Fuel Savings.", comment: "Best Value alert threshold read-back"), FuelSavingsFormat.money(bestValueThresholdGBP)))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if enabled && monitoredCount == 0 {
|
||||
Text("No stations monitored yet — open the Stations tab to load prices first.")
|
||||
Text(String(localized: "No stations monitored yet — open the Stations tab to load prices first.", comment: "No monitored stations hint"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} header: {
|
||||
Text("Cheapest-station alerts")
|
||||
Text(String(localized: "Proximity alerts", comment: "Proximity alerts section title"))
|
||||
} footer: {
|
||||
Text("When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling \(fuel.displayName.lowercased()) are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.")
|
||||
let modeDescription = alertMode == .bestValue
|
||||
? String(format: String(localized: "When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour.", comment: "Best Value proximity alerts section footer"), FuelSavingsFormat.money(bestValueThresholdGBP), fuel.displayName.lowercased())
|
||||
: String(format: String(localized: "When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.", comment: "Proximity alerts section footer"), fuel.displayName.lowercased())
|
||||
Text(modeDescription)
|
||||
}
|
||||
|
||||
if enabled, let lastAlert {
|
||||
Section("Last alert") {
|
||||
Section(String(localized: "Last alert", comment: "Last alert section title")) {
|
||||
Label(lastAlert, systemImage: "bell.badge.fill")
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Live Activity", isOn: $liveActivityEnabled)
|
||||
Picker("Fuel", selection: $liveActivityFuel) {
|
||||
Toggle(String(localized: "Cheapest station", comment: "Live Activity toggle"), isOn: $liveActivityEnabled)
|
||||
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $liveActivityFuel) {
|
||||
ForEach(FuelType.allCases) { fuel in
|
||||
Text(fuel.displayName).tag(fuel)
|
||||
}
|
||||
}
|
||||
Picker("Distance", selection: laChoice) {
|
||||
Text("Follow search (\(searchInUnitLabel))").tag(LADistanceChoice.followSearch)
|
||||
Picker(String(localized: "Distance", comment: "Distance picker title"), selection: laChoice) {
|
||||
Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(LADistanceChoice.followSearch)
|
||||
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
|
||||
Text(distanceLabel(forMiles: miles)).tag(LADistanceChoice.fixed(miles))
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Live Activity")
|
||||
Text(String(localized: "Live Activity", comment: "Live Activity section title"))
|
||||
} footer: {
|
||||
Text("Shows the cheapest station for the fuel and distance you pick here on the Lock Screen and Dynamic Island — independent of the Stations tab (choose Follow search to mirror its distance instead). Updates as you drive; tap to open directions. Live Activities can be disabled in System Settings → Live Activities.")
|
||||
Text(String(localized: "Shows the cheapest station for the fuel and distance you pick here on the Lock Screen and Dynamic Island — independent of the Stations tab (choose Follow search to mirror its distance instead). Updates as you drive; tap to open directions. Live Activities can be disabled in System Settings → Live Activities.", comment: "Live Activity section footer"))
|
||||
}
|
||||
}
|
||||
.navigationTitle("Alerts")
|
||||
.navigationTitle(String(localized: "Alerts", comment: "Alerts tab title"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,170 @@
|
||||
{
|
||||
"images" : [
|
||||
"images": [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
"filename": "AppIcon.png",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
"appearances": [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "AppIcon-dark.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
"filename": "AppIcon-dark.png",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
"appearances": [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
"appearance": "luminosity",
|
||||
"value": "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "AppIcon-tinted.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
"filename": "AppIcon-tinted.png",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename": "Watch-NotificationCenter-48.png",
|
||||
"idiom": "watch",
|
||||
"role": "notificationCenter",
|
||||
"scale": "2x",
|
||||
"size": "24x24",
|
||||
"subtype": "38mm"
|
||||
},
|
||||
{
|
||||
"filename": "Watch-NotificationCenter-55.png",
|
||||
"idiom": "watch",
|
||||
"role": "notificationCenter",
|
||||
"scale": "2x",
|
||||
"size": "27.5x27.5",
|
||||
"subtype": "42mm"
|
||||
},
|
||||
{
|
||||
"filename": "Watch-NotificationCenter-66.png",
|
||||
"idiom": "watch",
|
||||
"role": "notificationCenter",
|
||||
"scale": "2x",
|
||||
"size": "33x33",
|
||||
"subtype": "45mm"
|
||||
},
|
||||
{
|
||||
"filename": "Watch-CompanionSettings-58.png",
|
||||
"idiom": "watch",
|
||||
"role": "companionSettings",
|
||||
"scale": "2x",
|
||||
"size": "29x29"
|
||||
},
|
||||
{
|
||||
"filename": "Watch-CompanionSettings-87.png",
|
||||
"idiom": "watch",
|
||||
"role": "companionSettings",
|
||||
"scale": "3x",
|
||||
"size": "29x29"
|
||||
},
|
||||
{
|
||||
"filename": "Watch-AppLauncher-80.png",
|
||||
"idiom": "watch",
|
||||
"role": "appLauncher",
|
||||
"scale": "2x",
|
||||
"size": "40x40",
|
||||
"subtype": "38mm"
|
||||
},
|
||||
{
|
||||
"filename": "Watch-AppLauncher-88.png",
|
||||
"idiom": "watch",
|
||||
"role": "appLauncher",
|
||||
"scale": "2x",
|
||||
"size": "44x44",
|
||||
"subtype": "40mm"
|
||||
},
|
||||
{
|
||||
"filename": "Watch-AppLauncher-92.png",
|
||||
"idiom": "watch",
|
||||
"role": "appLauncher",
|
||||
"scale": "2x",
|
||||
"size": "46x46",
|
||||
"subtype": "41mm"
|
||||
},
|
||||
{
|
||||
"filename": "Watch-AppLauncher-100.png",
|
||||
"idiom": "watch",
|
||||
"role": "appLauncher",
|
||||
"scale": "2x",
|
||||
"size": "50x50",
|
||||
"subtype": "44mm"
|
||||
},
|
||||
{
|
||||
"filename": "Watch-AppLauncher-102.png",
|
||||
"idiom": "watch",
|
||||
"role": "appLauncher",
|
||||
"scale": "2x",
|
||||
"size": "51x51",
|
||||
"subtype": "45mm"
|
||||
},
|
||||
{
|
||||
"filename": "Watch-AppLauncher-108.png",
|
||||
"idiom": "watch",
|
||||
"role": "appLauncher",
|
||||
"scale": "2x",
|
||||
"size": "54x54",
|
||||
"subtype": "49mm"
|
||||
},
|
||||
{
|
||||
"filename": "Watch-QuickLook-172.png",
|
||||
"idiom": "watch",
|
||||
"role": "quickLook",
|
||||
"scale": "2x",
|
||||
"size": "86x86",
|
||||
"subtype": "38mm"
|
||||
},
|
||||
{
|
||||
"filename": "Watch-QuickLook-196.png",
|
||||
"idiom": "watch",
|
||||
"role": "quickLook",
|
||||
"scale": "2x",
|
||||
"size": "98x98",
|
||||
"subtype": "42mm"
|
||||
},
|
||||
{
|
||||
"filename": "Watch-QuickLook-216.png",
|
||||
"idiom": "watch",
|
||||
"role": "quickLook",
|
||||
"scale": "2x",
|
||||
"size": "108x108",
|
||||
"subtype": "44mm"
|
||||
},
|
||||
{
|
||||
"filename": "Watch-QuickLook-234.png",
|
||||
"idiom": "watch",
|
||||
"role": "quickLook",
|
||||
"scale": "2x",
|
||||
"size": "117x117",
|
||||
"subtype": "45mm"
|
||||
},
|
||||
{
|
||||
"filename": "Watch-QuickLook-258.png",
|
||||
"idiom": "watch",
|
||||
"role": "quickLook",
|
||||
"scale": "2x",
|
||||
"size": "129x129",
|
||||
"subtype": "49mm"
|
||||
},
|
||||
{
|
||||
"filename": "Watch-Marketing-1024.png",
|
||||
"idiom": "watch-marketing",
|
||||
"scale": "1x",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 747 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -4,6 +4,14 @@
|
||||
"filename" : "AppIconArtwork.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
"filename" : "AppIconPreview.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
"filename" : "AppIconPreviewDark.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
"filename" : "PreviewWallpaper.jpg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
{
|
||||
"images": [
|
||||
"images" : [
|
||||
{
|
||||
"filename": "brand_applegreen_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_applegreen_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
"filename" : "brand_applegreen_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_applegreen_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
{
|
||||
"images": [
|
||||
"images" : [
|
||||
{
|
||||
"filename": "brand_asda_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_asda_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
"filename" : "brand_asda_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_asda_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
{
|
||||
"images": [
|
||||
"images" : [
|
||||
{
|
||||
"filename": "brand_bp_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_bp_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
"filename" : "brand_bp_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_bp_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_circle_k_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_circle_k_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_essar_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_essar_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
@@ -1,18 +1,22 @@
|
||||
{
|
||||
"images": [
|
||||
"images" : [
|
||||
{
|
||||
"filename": "brand_esso_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_esso_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
"filename" : "brand_esso_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_esso_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_gleaner_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_gleaner_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
@@ -1,18 +1,22 @@
|
||||
{
|
||||
"images": [
|
||||
"images" : [
|
||||
{
|
||||
"filename": "brand_gulf_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_gulf_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
"filename" : "brand_gulf_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_gulf_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_highland_fuels_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_highland_fuels_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 636 B |
|
After Width: | Height: | Size: 970 B |
@@ -1,18 +1,22 @@
|
||||
{
|
||||
"images": [
|
||||
"images" : [
|
||||
{
|
||||
"filename": "brand_jet_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_jet_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
"filename" : "brand_jet_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_jet_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_maxol_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_maxol_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
@@ -1,18 +1,22 @@
|
||||
{
|
||||
"images": [
|
||||
"images" : [
|
||||
{
|
||||
"filename": "brand_morrisons_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_morrisons_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
"filename" : "brand_morrisons_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_morrisons_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_murco_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_murco_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -1,18 +1,22 @@
|
||||
{
|
||||
"images": [
|
||||
"images" : [
|
||||
{
|
||||
"filename": "brand_sainsburys_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_sainsburys_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
"filename" : "brand_sainsburys_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_sainsburys_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
{
|
||||
"images": [
|
||||
"images" : [
|
||||
{
|
||||
"filename": "brand_shell_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_shell_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
"filename" : "brand_shell_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_shell_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_spar_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_spar_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -1,18 +1,22 @@
|
||||
{
|
||||
"images": [
|
||||
"images" : [
|
||||
{
|
||||
"filename": "brand_tesco_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_tesco_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
"filename" : "brand_tesco_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_tesco_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
{
|
||||
"images": [
|
||||
"images" : [
|
||||
{
|
||||
"filename": "brand_texaco_2x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "2x"
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename": "brand_texaco_3x.png",
|
||||
"idiom": "universal",
|
||||
"scale": "3x"
|
||||
"filename" : "brand_texaco_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_texaco_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_the_co_operative_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_the_co_operative_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_valero_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_valero_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_welcome_break_2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "brand_welcome_break_3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,313 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The Best Value page body: what the feature does, whether to switch it on, and
|
||||
/// — only when it is on — the two figures the ranking needs, entered with the
|
||||
/// same sliders Settings uses (one control, no text entry, so a first-run user
|
||||
/// can't stall on a keyboard).
|
||||
///
|
||||
/// Shared deliberately. First-run onboarding shows it as one of its pages, and
|
||||
/// existing users who updated into the feature get the same page as a
|
||||
/// once-per-announcement-version announcement (`BestValueIntroView`). Two copies
|
||||
/// would drift the first time either was edited, so the copy, the controls and
|
||||
/// the "declining is a decision" rule all live here.
|
||||
///
|
||||
/// Settings are written to the store as the controls move — the same
|
||||
/// immediate-persist rule as Settings — so a host dismissed mid-page cannot lose
|
||||
/// a choice the user has already made.
|
||||
struct BestValueIntroContent: View {
|
||||
@Binding var settings: FuelSavingsSettings
|
||||
|
||||
/// What the host's primary button says. ONE label for both answers: the
|
||||
/// toggle already shows whether Best Value is being switched on, so a
|
||||
/// separate "Not now" for the declined case was second-guessing a choice the
|
||||
/// user could see on screen.
|
||||
static var primaryTitle: String {
|
||||
String(localized: "Continue", comment: "Best Value continue button")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 8)
|
||||
|
||||
AnimatedOnboardingIcon(tint: .green) {
|
||||
Image(systemName: "sterlingsign.circle.fill")
|
||||
.font(.system(size: 40, weight: .semibold))
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Text(String(localized: "Best Value", comment: "Best Value title"))
|
||||
.font(.largeTitle.bold())
|
||||
|
||||
Text(String(localized: "The cheapest pump isn't always the cheapest fill-up — driving further burns fuel. Best Value sorts stations by what you'd really save once the trip is counted.", comment: "Best Value explanation"))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 28)
|
||||
.padding(.top, 10)
|
||||
|
||||
card
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 16)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
}
|
||||
|
||||
private var card: some View {
|
||||
VStack(spacing: 14) {
|
||||
Toggle(isOn: enabledBinding) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(String(localized: "Turn on Best Value", comment: "Best Value toggle"))
|
||||
.font(.headline)
|
||||
Text(String(localized: "Sort by what a station saves you, travel included.", comment: "Best Value toggle detail"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if settings.enabled {
|
||||
slider(
|
||||
title: String(localized: "Average fuel economy", comment: "Best Value MPG slider label"),
|
||||
readOut: mpgText,
|
||||
value: mpgBinding,
|
||||
range: FuelSavingsCalculator.mpgSliderRange,
|
||||
step: FuelSavingsCalculator.mpgSliderStep
|
||||
)
|
||||
|
||||
Picker("Fill-up unit", selection: unitBinding) {
|
||||
ForEach(FuelSavingsFillUnit.allCases) { unit in
|
||||
Text(unit.displayName).tag(unit)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
slider(
|
||||
title: String(localized: "Typical fill-up", comment: "Best Value fill-up slider label"),
|
||||
readOut: fillUpText,
|
||||
value: amountBinding,
|
||||
range: FuelSavingsCalculator.fillAmountSliderRange(for: settings.preferredAmount.unit),
|
||||
step: FuelSavingsCalculator.fillAmountSliderStep(for: settings.preferredAmount.unit)
|
||||
)
|
||||
|
||||
Text(String(localized: "Best Value uses these, and can be changed any time in Settings.", comment: "Best Value configured note"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text(String(localized: "That's fine — you can turn Best Value on whenever you like in Settings, under Fuel Savings.", comment: "Best Value declined note"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
)
|
||||
}
|
||||
|
||||
/// The sliders are the only controls for these two figures, exactly as in
|
||||
/// Settings: a labelled read-out above a slider, no text field.
|
||||
private func slider(title: String,
|
||||
readOut: String,
|
||||
value: Binding<Double>,
|
||||
range: ClosedRange<Double>,
|
||||
step: Double) -> some View {
|
||||
VStack(spacing: 2) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Text(readOut)
|
||||
.font(.subheadline)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Slider(value: value, in: range, step: step)
|
||||
.accessibilityLabel(title)
|
||||
.accessibilityValue(readOut)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Bindings (persist immediately, exactly like Settings)
|
||||
|
||||
private var enabledBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { settings.enabled },
|
||||
set: { settings.enabled = $0; persist() }
|
||||
)
|
||||
}
|
||||
|
||||
private var mpgBinding: Binding<Double> {
|
||||
Binding(
|
||||
get: { settings.mpg },
|
||||
set: {
|
||||
settings.mpg = min(max($0, 0), FuelSavingsCalculator.maximumMPG)
|
||||
persist()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var amountBinding: Binding<Double> {
|
||||
Binding(
|
||||
get: { settings.preferredAmount.value },
|
||||
set: {
|
||||
settings.preferredAmount.value = min(
|
||||
max($0, 0),
|
||||
FuelSavingsCalculator.maximumFillAmount(for: settings.preferredAmount.unit)
|
||||
)
|
||||
persist()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var unitBinding: Binding<FuelSavingsFillUnit> {
|
||||
Binding(
|
||||
get: { settings.preferredAmount.unit },
|
||||
set: { newUnit in
|
||||
settings.preferredAmount.unit = newUnit
|
||||
// Same guard as Settings: a figure valid in one unit may be
|
||||
// valid in neither after the switch, so fall back to a sensible
|
||||
// default for the new unit rather than storing junk.
|
||||
if !settings.preferredAmount.isValid {
|
||||
settings.preferredAmount.value = newUnit == .pounds ? 40 : 30
|
||||
}
|
||||
persist()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
FuelStore.saveFuelSavingsSettings(settings)
|
||||
}
|
||||
|
||||
/// "45 MPG" / "45.5 MPG" — the slider is the only control, so this read-out
|
||||
/// is the one place the figure is stated.
|
||||
private var mpgText: String {
|
||||
let mpg = settings.mpg
|
||||
return mpg.rounded() == mpg ? String(format: "%.0f MPG", mpg) : String(format: "%.1f MPG", mpg)
|
||||
}
|
||||
|
||||
/// "£40" / "40 L" — the fill-up slider's read-out.
|
||||
private var fillUpText: String {
|
||||
switch settings.preferredAmount.unit {
|
||||
case .pounds: return String(format: "£%.0f", settings.preferredAmount.value)
|
||||
case .litres: return String(format: "%.0f L", settings.preferredAmount.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The announcement existing users see once per announcement version: the Best
|
||||
/// Value page on its own. Deliberately NOT the onboarding pager — no page dots
|
||||
/// and no permission pages, because those users answered all of that long ago
|
||||
/// and the full flow is mandatory with no skip.
|
||||
struct BestValueIntroView: View {
|
||||
var onFinish: () -> Void
|
||||
|
||||
@State private var settings = FuelStore.loadFuelSavingsSettings()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
BestValueIntroContent(settings: $settings)
|
||||
|
||||
OnboardingPrimaryButton(title: BestValueIntroContent.primaryTitle,
|
||||
action: finish)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [Color(.systemBackground), Color.accentColor.opacity(0.06)],
|
||||
startPoint: .top, endPoint: .bottom
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
)
|
||||
}
|
||||
|
||||
private func finish() {
|
||||
// Marked on the way OUT, so a crash or force-quit mid-page re-shows the
|
||||
// announcement instead of silently swallowing it.
|
||||
FuelStore.markBestValueIntroSeen()
|
||||
onFinish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Presents the Best Value announcement over the app, exactly once per
|
||||
/// announcement version. It wraps whatever it is given, so the whole feature —
|
||||
/// state, gate and dismissal — lives in this file and ContentView only has to
|
||||
/// install it.
|
||||
///
|
||||
/// The gate, in order:
|
||||
/// - suppressed under `-skipOnboarding` (DEBUG), so screenshot runs can open a
|
||||
/// tab without a cover interrupting the capture;
|
||||
/// - forced by `-showBestValueIntro` (DEBUG);
|
||||
/// - never while first-run onboarding is due or on screen — that flow contains
|
||||
/// the same page, and `OnboardingView.finish` marks this seen;
|
||||
/// - never when the stored announcement version is current;
|
||||
/// - never for someone who has already configured Best Value themselves.
|
||||
struct BestValueAnnouncementHost<Content: View>: View {
|
||||
/// True while the first-run onboarding cover is up: the announcement must
|
||||
/// not fight it for the screen.
|
||||
var isSuppressed: Bool = false
|
||||
var onDismiss: () -> Void = {}
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
@State private var presenting = false
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.fullScreenCover(isPresented: $presenting) {
|
||||
BestValueIntroView {
|
||||
presenting = false
|
||||
}
|
||||
}
|
||||
// `task`, not `onAppear`: runs once when the app's content appears,
|
||||
// after ContentView has made its onboarding decision.
|
||||
.task {
|
||||
guard !presenting, shouldAnnounce else { return }
|
||||
presenting = true
|
||||
}
|
||||
.onChange(of: presenting) { _, showing in
|
||||
guard !showing else { return }
|
||||
// The page writes straight to the store — it is its own cover
|
||||
// with its own copy of the settings — so the app's in-memory
|
||||
// copy has to be pulled back in, or an announcement choice
|
||||
// wouldn't apply until the next launch.
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private var shouldAnnounce: Bool {
|
||||
#if DEBUG
|
||||
let args = ProcessInfo.processInfo.arguments
|
||||
if args.contains("-skipOnboarding") { return false }
|
||||
if args.contains("-showBestValueIntro") { return true }
|
||||
#endif
|
||||
// The rule itself lives in the store so it can be unit-tested — see
|
||||
// `FuelStore.shouldAnnounceBestValue`.
|
||||
return FuelStore.shouldAnnounceBestValue(
|
||||
settings: FuelStore.loadFuelSavingsSettings(),
|
||||
isSuppressed: isSuppressed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The prominent button both Best Value hosts use, so they can't drift apart.
|
||||
struct OnboardingPrimaryButton: View {
|
||||
var title: String
|
||||
var action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,17 @@ struct FavouritesView: View {
|
||||
let location: Coordinate?
|
||||
let distanceUnit: DistanceUnit
|
||||
let priceDisplayStyle: PriceDisplayStyle
|
||||
let stationTapAction: StationTapAction
|
||||
/// Fuel Savings config. When it is configured this tab shows the SAME
|
||||
/// fill-up control and per-row estimate as the Stations list: one session
|
||||
/// amount is shared, so adjusting it in either list updates both, and it
|
||||
/// still never touches the Settings default.
|
||||
let fuelSavingsSettings: FuelSavingsSettings
|
||||
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
|
||||
/// Estimated saving for a favourite, against the closest station by
|
||||
/// journey distance — the same baseline and definition the Stations list uses.
|
||||
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
||||
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||
/// Persists a reordered favourites array (after drag-and-drop).
|
||||
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
||||
@@ -37,6 +48,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 +79,11 @@ struct FavouritesView: View {
|
||||
location: Coordinate?,
|
||||
distanceUnit: DistanceUnit,
|
||||
priceDisplayStyle: PriceDisplayStyle,
|
||||
stationTapAction: StationTapAction,
|
||||
fuelSavingsSettings: FuelSavingsSettings = .defaults,
|
||||
fuelSavingsAmount: Binding<FuelSavingsFillAmount>,
|
||||
savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil },
|
||||
journeyDiagnosticsForStation: @escaping (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil },
|
||||
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
||||
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
||||
onHistoryUnavailable: (() -> Void)? = nil,
|
||||
@@ -74,6 +93,11 @@ struct FavouritesView: View {
|
||||
self.location = location
|
||||
self.distanceUnit = distanceUnit
|
||||
self.priceDisplayStyle = priceDisplayStyle
|
||||
self.stationTapAction = stationTapAction
|
||||
self.fuelSavingsSettings = fuelSavingsSettings
|
||||
_fuelSavingsAmount = fuelSavingsAmount
|
||||
self.savingsForStation = savingsForStation
|
||||
self.journeyDiagnosticsForStation = journeyDiagnosticsForStation
|
||||
self.onToggleFavourite = onToggleFavourite
|
||||
self.onReorder = onReorder
|
||||
self.onHistoryUnavailable = onHistoryUnavailable
|
||||
@@ -100,9 +124,9 @@ struct FavouritesView: View {
|
||||
Image(systemName: "star")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No favourites yet")
|
||||
Text(String(localized: "No favourites yet", comment: "Favourites empty-state title"))
|
||||
.font(.headline)
|
||||
Text("Tap the star on any station in the Stations tab to pin it here. The first station in each list becomes the favourite used in single widgets — drag to put your pick first.")
|
||||
Text(String(localized: "Tap the star on any station in the Stations tab to pin it here. The first station in each list becomes the favourite used in single widgets — drag to put your pick first.", comment: "Favourites empty-state explanation"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -111,13 +135,21 @@ struct FavouritesView: View {
|
||||
.padding(.vertical, 24)
|
||||
}
|
||||
} else {
|
||||
Section("Fuel type") {
|
||||
Section(String(localized: "Fuel type", comment: "Fuel type section title")) {
|
||||
FuelTypeSegmentedPicker(
|
||||
selection: $fuel,
|
||||
fuels: availableFuels
|
||||
)
|
||||
}
|
||||
|
||||
if fuelSavingsSettings.isConfigured {
|
||||
Section {
|
||||
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
|
||||
} footer: {
|
||||
Text(String(localized: "Savings below are estimated against the closest station by journey distance — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation"))
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(Array(ordered.enumerated()), id: \.element.id) { index, station in
|
||||
StationRow(
|
||||
@@ -127,28 +159,31 @@ struct FavouritesView: View {
|
||||
distanceUnit: distanceUnit,
|
||||
priceDisplayStyle: priceDisplayStyle,
|
||||
baselinePrice: cheapestPrice,
|
||||
isTopResult: index == 0,
|
||||
isTopResult: station.id == cheapest?.id,
|
||||
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
||||
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
|
||||
tapAction: stationTapAction,
|
||||
fuelSavings: savingsForStation(station),
|
||||
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
|
||||
onShowDetails: { detailStation = $0 }
|
||||
)
|
||||
}
|
||||
.onMove(perform: moveFavourite)
|
||||
} header: {
|
||||
Text("\(activeFuel.displayName) favourites")
|
||||
Text(String(format: String(localized: "%@ favourites", comment: "Favourites section title for fuel type"), activeFuel.displayName))
|
||||
} footer: {
|
||||
Text("The first station is the favourite used in single widgets. Tap Edit, then drag to reorder.")
|
||||
Text(String(localized: "The first station is the favourite used in single widgets. Tap Edit, then drag to reorder.", comment: "Favourites reorder explanation"))
|
||||
}
|
||||
|
||||
if let cheapest = cheapest {
|
||||
Section {
|
||||
Text("\(cheapest.name) is your cheapest \(activeFuel.displayName) favourite. Favourites are monitored automatically — switch to the Alerts tab and enable alerts to get pinged when you approach one that's the cheapest within the radius.")
|
||||
Text(String(format: String(localized: "%@ is your cheapest %@ favourite. Favourites are monitored automatically — switch to the Alerts tab and enable alerts to get pinged when you approach one that's the cheapest within the radius.", comment: "Cheapest favourite explanation"), cheapest.name, activeFuel.displayName))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Favourites")
|
||||
.navigationTitle(String(localized: "Favourites", comment: "Favourites tab title"))
|
||||
.toolbar {
|
||||
if !favourites.isEmpty {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
@@ -156,7 +191,7 @@ struct FavouritesView: View {
|
||||
showTrends = true
|
||||
} label: {
|
||||
Image(systemName: "chart.xyaxis.line")
|
||||
.accessibilityLabel("Trends")
|
||||
.accessibilityLabel(String(localized: "Trends", comment: "Trends button accessibility label"))
|
||||
}
|
||||
EditButton()
|
||||
}
|
||||
@@ -171,6 +206,21 @@ struct FavouritesView: View {
|
||||
onHistoryRecovered: onHistoryRecovered
|
||||
)
|
||||
}
|
||||
.sheet(item: $detailStation) { station in
|
||||
StationDetailView(
|
||||
station: station,
|
||||
location: location,
|
||||
distanceUnit: distanceUnit,
|
||||
selectedFuel: activeFuel,
|
||||
fuelSavings: savingsForStation(station),
|
||||
fuelSavingsAmount: fuelSavingsAmount,
|
||||
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
||||
// Every station listed here IS a favourite for the tab's
|
||||
// fuel, so the star starts filled; tapping it unstars.
|
||||
isFavourite: true,
|
||||
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
#if DEBUG
|
||||
// QA hook: launch with `-showTrends` to open the sheet
|
||||
|
||||
@@ -1,14 +1,87 @@
|
||||
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()
|
||||
rootView
|
||||
.onOpenURL(perform: handleOpenURL)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchRefreshRequested)) { _ in
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
}
|
||||
}
|
||||
.backgroundTask(.appRefresh(SmartDataRefreshScheduler.taskIdentifier)) {
|
||||
await SmartDataRefreshCoordinator.runBackgroundProbe()
|
||||
}
|
||||
}
|
||||
|
||||
/// QA hook (Debug builds only): `-qaScreenMarker` paints the whole screen a
|
||||
/// flat magenta. A macOS capture of the Simulator window can then locate the
|
||||
/// device-screen rectangle exactly, which yields a pixel-accurate mapping
|
||||
/// from simulator screenshots to click coordinates. That is the only way to
|
||||
/// drive REAL taps into the Simulator: `simctl` cannot, and this project has
|
||||
/// no UI-test target. Everything else renders ContentView as normal.
|
||||
@ViewBuilder private var rootView: some View {
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("-qaScreenMarker") {
|
||||
Color(red: 1, green: 0, blue: 1).ignoresSafeArea()
|
||||
} else {
|
||||
ContentView()
|
||||
}
|
||||
#else
|
||||
ContentView()
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private func startQALiveActivity(fuel: FuelType) {
|
||||
let state = FuelBoardLiveActivityAttributes.ContentState(
|
||||
fuel: fuel,
|
||||
stationID: "qa-phoenix",
|
||||
stationName: "Phoenix Filling Stations",
|
||||
brand: "Phoenix",
|
||||
pricePence: 1499,
|
||||
priceDisplayStyle: FuelStore.loadPriceDisplayStyle(),
|
||||
distanceKM: 8.0,
|
||||
// QA mock: marked approximate so the hook renders the LONGEST
|
||||
// distance form ("Approx. 8.0 mi") and the island / Lock Screen
|
||||
// layout can be checked against it.
|
||||
distanceIsApproximate: true,
|
||||
lat: 51.5,
|
||||
lng: -0.12,
|
||||
updatedAt: Date()
|
||||
)
|
||||
let attrs = FuelBoardLiveActivityAttributes()
|
||||
do {
|
||||
let activity = try Activity.request(
|
||||
attributes: attrs,
|
||||
content: .init(state: state, staleDate: nil),
|
||||
pushType: nil
|
||||
)
|
||||
print("QA-LIVE-ACTIVITY STARTED id=\(activity.id)")
|
||||
} catch {
|
||||
print("QA-LIVE-ACTIVITY FAILED: \(error)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Handles deep links that end up in the app. Widget taps arrive here in
|
||||
/// two cases:
|
||||
/// - legacy/cached widget timelines using the `fuelboard://` relay, or
|
||||
@@ -18,9 +91,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).
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FuelSavingsAdjustmentView: View {
|
||||
@Binding var amount: FuelSavingsFillAmount
|
||||
|
||||
@State private var unitChangeNotice: String?
|
||||
|
||||
private var range: ClosedRange<Double> {
|
||||
FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit)
|
||||
}
|
||||
|
||||
private var step: Double {
|
||||
FuelSavingsCalculator.fillAmountSliderStep(for: amount.unit)
|
||||
}
|
||||
|
||||
/// Read-out of the figure the slider is on. Deliberately NOT a text field:
|
||||
/// a typed figure could sit outside the slider's own bounds, leaving the
|
||||
/// control and the value it claimed to show disagreeing. Slider-only means
|
||||
/// what you see is the figure the estimate actually uses.
|
||||
private var valueText: String {
|
||||
switch amount.unit {
|
||||
case .pounds: return String(format: "£%.0f", amount.value)
|
||||
case .litres: return String(format: "%.0f L", amount.value)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("How much are you filling up?")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Picker("Fill-up unit", selection: unitBinding) {
|
||||
ForEach(FuelSavingsFillUnit.allCases) { unit in
|
||||
Text(unit.displayName).tag(unit)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
HStack {
|
||||
Slider(value: sliderBinding, in: range, step: step)
|
||||
.accessibilityLabel("Fill-up amount")
|
||||
.accessibilityValue(valueText)
|
||||
Text(valueText)
|
||||
.monospacedDigit()
|
||||
.frame(width: 72, alignment: .trailing)
|
||||
}
|
||||
Text("Changes the estimate for this session only — your Settings default stays unchanged.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("£ compares the litres your budget buys at each station; Litres compares one fixed quantity.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
if let unitChangeNotice {
|
||||
Text(unitChangeNotice)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var unitBinding: Binding<FuelSavingsFillUnit> {
|
||||
Binding(
|
||||
get: { amount.unit },
|
||||
set: { newUnit in
|
||||
guard newUnit != amount.unit else { return }
|
||||
// No one litre figure equals a budget at every candidate price.
|
||||
// A unit change starts a new, explicitly labelled comparison.
|
||||
amount = FuelSavingsCalculator.newComparisonAmount(afterSwitching: amount, to: newUnit)
|
||||
unitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up."
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var sliderBinding: Binding<Double> {
|
||||
Binding(
|
||||
get: { min(max(amount.value, range.lowerBound), range.upperBound) },
|
||||
set: { amount.value = min(max($0, range.lowerBound), range.upperBound) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct FuelSavingsSummaryView: View {
|
||||
let availability: FuelSavingsAvailability?
|
||||
let amount: FuelSavingsFillAmount
|
||||
var baselineStationName: String? = nil
|
||||
var compact: Bool = false
|
||||
/// Sizes the "extra return travel" caption in the user's own unit (the
|
||||
/// stored figure is miles) — used by both the row banner and the detail sheet.
|
||||
var distanceUnit: DistanceUnit = .miles
|
||||
var showInfoAction: (() -> Void)? = nil
|
||||
|
||||
private func comparisonLabel(for result: FuelSavingsResult) -> String {
|
||||
FuelSavingsFormat.comparisonLabel(status: result.status)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
switch availability {
|
||||
case .available(let result):
|
||||
if compact {
|
||||
// FULL-WIDTH banner under the row. Inline inside the middle
|
||||
// column the capsule truncated ("Approx. £2.75 better off after
|
||||
// tra…") because
|
||||
// the column is squeezed by the price block — the verdict is the
|
||||
// point of this sort mode, so it gets its own line with room.
|
||||
// Colour carries the verdict: green save, red worse off, grey
|
||||
// break even, blue baseline. lineLimit + minimumScaleFactor keep
|
||||
// it to one line, so rows stay even.
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
// The capsule gets the WHOLE line and the comparison caption
|
||||
// sits beneath it. Side by side, the caption's layoutPriority
|
||||
// squeezed the verdict until it truncated ("Approx. £2.75
|
||||
// better off after tra…"), and in this sort mode the verdict
|
||||
// is the point of the row.
|
||||
capsule(for: result, compact: true)
|
||||
.accessibilityLabel(accessibilityHeadline(for: result))
|
||||
Text(comparisonLabel(for: result))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
|
||||
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
|
||||
extraDistanceMiles: result.extraDistanceMiles,
|
||||
unit: distanceUnit,
|
||||
confidence: result.journeyConfidence
|
||||
) {
|
||||
Text(extraTravel)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
.accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel))
|
||||
}
|
||||
}
|
||||
// Aligns the banner with the row's text column (46 pt logo + 12
|
||||
// pt spacing) so it reads as part of the row, not the cell.
|
||||
.padding(.leading, 58)
|
||||
} else {
|
||||
// Detail sheet: the SAME verdict capsule and the SAME caption as
|
||||
// the listing rows (one implementation, so wording and colour
|
||||
// cannot drift), followed by the breakdown only this view has
|
||||
// room for. The old shape led with a status word + headline that
|
||||
// matched neither the listing nor each other.
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
// Same shape as the listing rows: the capsule takes the line
|
||||
// and the comparison caption sits directly beneath it, so the
|
||||
// verdict never competes for width with its own caption.
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
capsule(for: result, compact: false)
|
||||
.accessibilityLabel(accessibilityHeadline(for: result))
|
||||
Text(comparisonLabel(for: result))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
.accessibilityLabel("Comparison: \(comparisonLabel(for: result))")
|
||||
}
|
||||
LabeledContent("Your fill-up", value: amountText)
|
||||
LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres))
|
||||
.monospacedDigit()
|
||||
if amount.unit == .pounds {
|
||||
Text("You pay the selected budget here. The saving compares the same litres at your closest station; litres differ between stations, and this is not a refund.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let extraTravel = FuelSavingsFormat.extraTravelLabel(
|
||||
extraDistanceMiles: result.extraDistanceMiles,
|
||||
unit: distanceUnit,
|
||||
confidence: result.journeyConfidence
|
||||
) {
|
||||
Text(extraTravel)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel(spokenExtraTravel(for: result, fallback: extraTravel))
|
||||
}
|
||||
if result.status == .baseline {
|
||||
Text("Your closest station by journey distance is the baseline — every other saving is measured against it.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text(baselineStationName.map { "Compared with \($0)." } ?? "Compared with your closest station.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
LabeledContent(
|
||||
result.grossSavingGBP >= 0 ? "Fuel-price saving" : "Fuel-price difference",
|
||||
value: result.grossSavingGBP >= 0
|
||||
? FuelSavingsFormat.money(result.grossSavingGBP)
|
||||
: "Costs \(FuelSavingsFormat.money(abs(result.grossSavingGBP))) more"
|
||||
)
|
||||
LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP))
|
||||
Divider()
|
||||
LabeledContent {
|
||||
Text(result.headline())
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(capsuleColor(for: result.status))
|
||||
.monospacedDigit()
|
||||
} label: {
|
||||
Text("Total")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
if result.journeyConfidence.isEstimated {
|
||||
Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Estimate covers fuel cost only. It excludes time, parking, tolls and wear.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let showInfoAction {
|
||||
Rectangle()
|
||||
.fill(Color(.separator))
|
||||
.frame(height: 0.5)
|
||||
.padding(.vertical, 4)
|
||||
Button(action: showInfoAction) {
|
||||
HStack(spacing: 8) {
|
||||
Text("How Best Value works")
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.forward")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
case .unavailable(let reason):
|
||||
Text(compact ? "Savings unavailable" : "Best Value unavailable: \(reason)")
|
||||
.font(compact ? .caption2 : .caption)
|
||||
.foregroundStyle(.secondary)
|
||||
case nil:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var amountText: String {
|
||||
switch amount.unit {
|
||||
case .pounds: return String(format: "£%.0f", amount.value)
|
||||
case .litres: return String(format: "%.0f litres", amount.value)
|
||||
}
|
||||
}
|
||||
|
||||
/// VoiceOver form of the extra-travel caption ("Approximately 6.4 miles extra
|
||||
/// return travel"): the abbreviation is spelled out and the unit is a word.
|
||||
/// Falls back to the visible string if no spoken form exists.
|
||||
private func spokenExtraTravel(for result: FuelSavingsResult, fallback: String) -> String {
|
||||
FuelSavingsFormat.extraTravelSpokenLabel(
|
||||
extraDistanceMiles: result.extraDistanceMiles,
|
||||
unit: distanceUnit,
|
||||
confidence: result.journeyConfidence
|
||||
) ?? fallback
|
||||
}
|
||||
|
||||
/// VoiceOver form of the verdict. `spokenHeadline()` spells "Approx." out for
|
||||
/// and leaves the plain words ("Break even", "Closest station") alone —
|
||||
/// one source, so visible and spoken copy cannot drift.
|
||||
private func accessibilityHeadline(for result: FuelSavingsResult) -> String {
|
||||
result.spokenHeadline()
|
||||
}
|
||||
|
||||
/// The colour-coded verdict capsule lives in `FuelSavingsCapsule` — ONE
|
||||
/// implementation for the listing rows, the detail sheet and this compact
|
||||
/// summary, so the verdict reads identically everywhere; the sheet just
|
||||
/// gets the larger font and padding.
|
||||
private func capsule(for result: FuelSavingsResult, compact: Bool) -> some View {
|
||||
FuelSavingsCapsule(result: result, compact: compact)
|
||||
}
|
||||
|
||||
/// Verdict colour lives in `FuelSavingsCapsule.color(for:)`.
|
||||
private func capsuleColor(for status: FuelSavingsStatus) -> Color {
|
||||
FuelSavingsCapsule.color(for: status)
|
||||
}
|
||||
|
||||
private func symbol(for status: FuelSavingsStatus) -> String {
|
||||
FuelSavingsCapsule.symbol(for: status)
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour-coded verdict capsule shared by the station listing rows, the
|
||||
/// detail sheet and the compact summary.
|
||||
///
|
||||
/// An HStack with an explicit spacing, NOT a Label: given the width to spare,
|
||||
/// a Label lays its icon and title out across the row and leaves a visible gap
|
||||
/// between them (22pt in the listing row), which read as the text having
|
||||
/// drifted away from its symbol. The icon is decorative here — the verdict is
|
||||
/// in the text — so VoiceOver reads the capsule as one phrase.
|
||||
struct FuelSavingsCapsule: View {
|
||||
let result: FuelSavingsResult
|
||||
var compact: Bool = true
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: compact ? 3 : 5) {
|
||||
Image(systemName: Self.symbol(for: result.status))
|
||||
.accessibilityHidden(true)
|
||||
Text(result.capsuleHeadline())
|
||||
.lineLimit(1)
|
||||
// Measured on a 393pt screen: the capsule is handed ~101pt, of
|
||||
// which the icon, its spacing and the 8pt padding either side
|
||||
// take 32pt, leaving ~69pt for the text. "Closest station" needs
|
||||
// ~90pt at full size, so a 0.8 floor — 72pt — could not reach it
|
||||
// and the row showed "Closest stati…" instead. 0.7 leaves ~63pt,
|
||||
// which clears it at ~10pt type: scaled, never truncated.
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
.font(compact ? .caption.weight(.semibold) : .headline)
|
||||
.foregroundStyle(Self.color(for: result.status))
|
||||
.padding(.horizontal, compact ? 8 : 12)
|
||||
.padding(.vertical, compact ? 4 : 7)
|
||||
.background(Capsule().fill(Self.color(for: result.status).opacity(0.15)))
|
||||
}
|
||||
|
||||
/// Verdict colour: green save, red worse off, grey break even, blue
|
||||
/// baseline — the same green/amber/red family the price dot already uses.
|
||||
static func color(for status: FuelSavingsStatus) -> Color {
|
||||
switch status {
|
||||
case .worthTheDrive: return .green
|
||||
case .notWorthTheDrive: return .red
|
||||
case .breakEven: return .secondary
|
||||
case .baseline: return .blue
|
||||
}
|
||||
}
|
||||
|
||||
static func symbol(for status: FuelSavingsStatus) -> String {
|
||||
switch status {
|
||||
case .worthTheDrive: return "checkmark.circle.fill"
|
||||
case .breakEven: return "equal.circle.fill"
|
||||
case .notWorthTheDrive: return "xmark.circle.fill"
|
||||
case .baseline: return "location.circle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The "How Best Value works" explanation as its own sheet.
|
||||
///
|
||||
/// It used to sit inline in the Key sheet as a `Section`; it is one shared view
|
||||
/// now so the Key sheet and the More info sheet can both open it, and the copy is
|
||||
/// unchanged (the same `Localizable.strings` keys are reused — the old section
|
||||
/// header string is the sheet's title).
|
||||
struct HowBestValueSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Best Value compares each station with the closest station by journey distance. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.")
|
||||
Text("Your fill-up amount and MPG are used to show whether you could save overall.")
|
||||
Text("With a £ budget you spend that amount at each station; the litres differ by station. Savings compare those litres with the same quantity at your closest station, then deduct extra journey fuel. You do not receive cash back.")
|
||||
Text("With Litres, every station is compared for the fixed quantity you selected.")
|
||||
Text("Results are marked “Approx.” until road distances are available.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.subheadline)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.navigationTitle("How Best Value works")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.fraction(0.42)])
|
||||
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||
}
|
||||
}
|
||||
|
||||
/// The row that opens `HowBestValueSheet`. A tappable row with a chevron, like
|
||||
/// the other rows in both sheets. `.buttonStyle(.plain)` keeps it from becoming a
|
||||
/// row-wide tap target that could swallow a neighbouring control (see the
|
||||
/// two-buttons-in-one-row rule).
|
||||
struct HowBestValueRow: View {
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 8) {
|
||||
Text("How Best Value works")
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.forward")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// QA hook support: `-showBestValueInfo` opens this sheet without a tap, so it can
|
||||
/// be screenshotted (simctl has no tap command) — the same pattern as
|
||||
/// `-showKeySheet` / `-showDetail`.
|
||||
enum BestValueInfoQA {
|
||||
static var isRequested: Bool {
|
||||
ProcessInfo.processInfo.arguments.contains("-showBestValueInfo")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
extension View {
|
||||
/// Puts a Done button above the keyboard.
|
||||
///
|
||||
/// The numeric fields (Settings → Average fuel economy and Typical
|
||||
/// fill-up, and the Worth the Drive fill-up box in the station list) use
|
||||
/// `.decimalPad`, which has NO Return key. Without this there is no way to
|
||||
/// dismiss the keyboard once such a field is tapped, and a
|
||||
/// `TextField(value:format:)` only commits the typed value when editing
|
||||
/// ends — so the number never stuck either.
|
||||
///
|
||||
/// WHY IT IS DRAWN HERE RATHER THAN AS `.toolbar(placement: .keyboard)`:
|
||||
/// the keyboard-toolbar form is device-dependent. The same build showed the
|
||||
/// Done button on the iPhone 15 Pro and NOT on the iPhone SE (both iOS
|
||||
/// 27.0.1, same install, same binary), leaving the SE with no way to
|
||||
/// dismiss or commit a numeric field. Drawing the bar in the screen's own
|
||||
/// bottom safe area renders identically on every device, so the accessibility
|
||||
/// of the numeric fields no longer depends on the accessory view appearing.
|
||||
///
|
||||
/// Apply ONCE per screen, to the list or form containing the fields: any
|
||||
/// keyboard on that screen shows the bar, so attaching it to each field
|
||||
/// would stack several Done buttons.
|
||||
///
|
||||
/// - Parameter action: clears the screen's focus state. Prefer this where a
|
||||
/// `@FocusState` exists; the default resigns the current first responder,
|
||||
/// which works for fields inside subviews that have no focus binding (the
|
||||
/// fill-up box in the station list is one).
|
||||
func keyboardDoneButton(action: (() -> Void)? = nil) -> some View {
|
||||
modifier(KeyboardDoneBar(action: action))
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a Done bar while a keyboard is up on this screen.
|
||||
///
|
||||
/// Visibility comes from the keyboard notifications rather than a `@FocusState`
|
||||
/// so the bar also works for fields inside subviews that have no focus binding.
|
||||
private struct KeyboardDoneBar: ViewModifier {
|
||||
let action: (() -> Void)?
|
||||
|
||||
@State private var isKeyboardVisible = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
if isKeyboardVisible {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Done") {
|
||||
if let action {
|
||||
action()
|
||||
} else {
|
||||
UIApplication.shared.sendAction(
|
||||
#selector(UIResponder.resignFirstResponder),
|
||||
to: nil,
|
||||
from: nil,
|
||||
for: nil
|
||||
)
|
||||
}
|
||||
isKeyboardVisible = false
|
||||
}
|
||||
.font(.body.weight(.semibold))
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.bar)
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in
|
||||
isKeyboardVisible = true
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in
|
||||
isKeyboardVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,11 @@ enum LiveActivityManager {
|
||||
return
|
||||
}
|
||||
|
||||
// ONE cache read for the figure AND its confidence: the activity shows
|
||||
// "Approx." exactly when the app's own rows would.
|
||||
let distance = FuelStore.stationDistanceDisplay(
|
||||
station: best, userLat: location.lat, userLng: location.lng
|
||||
)
|
||||
let state = FuelBoardLiveActivityAttributes.ContentState(
|
||||
fuel: fuel,
|
||||
stationID: best.id,
|
||||
@@ -57,7 +62,8 @@ enum LiveActivityManager {
|
||||
brand: best.brand,
|
||||
pricePence: price,
|
||||
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
|
||||
distanceKM: best.distanceKM(to: location.lat, lng2: location.lng),
|
||||
distanceKM: distance.km,
|
||||
distanceIsApproximate: distance.isApproximate,
|
||||
lat: best.lat,
|
||||
lng: best.lng,
|
||||
updatedAt: Date()
|
||||
@@ -73,7 +79,7 @@ enum LiveActivityManager {
|
||||
currentStartDate = nil
|
||||
start(attributes: attributes, state: state)
|
||||
} else {
|
||||
Task { await current.update(using: state) }
|
||||
Task { await current.update(ActivityContent(state: state, staleDate: nil)) }
|
||||
}
|
||||
} else {
|
||||
start(attributes: attributes, state: state)
|
||||
@@ -119,14 +125,20 @@ enum LiveActivityManager {
|
||||
lat: Double,
|
||||
lng: Double
|
||||
) -> FuelStation? {
|
||||
// Radius and tie-break both use the distance the pill SHOWS, so the
|
||||
// activity can never name a station the app's own list has filtered out.
|
||||
stations
|
||||
.filter { $0.prices[fuel] != nil }
|
||||
.filter { $0.distanceKM(to: lat, lng2: lng) <= radiusKM }
|
||||
.filter {
|
||||
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
|
||||
userLat: lat, userLng: lng)
|
||||
}
|
||||
.min { lhs, rhs in
|
||||
let lPrice = lhs.prices[fuel]!
|
||||
let rPrice = rhs.prices[fuel]!
|
||||
if lPrice != rPrice { return lPrice < rPrice }
|
||||
return lhs.distanceKM(to: lat, lng2: lng) < rhs.distanceKM(to: lat, lng2: lng)
|
||||
return FuelStore.displayDistanceKM(station: lhs, userLat: lat, userLng: lng)
|
||||
< FuelStore.displayDistanceKM(station: rhs, userLat: lat, userLng: lng)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,31 @@ import CoreLocation
|
||||
import UserNotifications
|
||||
import Network
|
||||
|
||||
/// First-launch onboarding: introduces FuelBoard, then walks the user through
|
||||
/// the three system permissions (location, notifications, local network for
|
||||
/// data loading) with in-context prompts. In production it appears once at
|
||||
/// initial launch (driven by `FuelStore.loadHasCompletedOnboarding()`); a
|
||||
/// test button in the Alerts tab re-opens it anytime.
|
||||
/// First-launch onboarding: introduces FuelBoard, walks the user through the
|
||||
/// two system permissions (location and notifications) with in-context prompts,
|
||||
/// then offers Best Value — explained, switched on or declined, and configured
|
||||
/// with the same sliders Settings uses. Price data downloads over HTTPS with no
|
||||
/// Local Network permission. In production it appears once at initial launch
|
||||
/// (driven by `FuelStore.loadHasCompletedOnboarding()`); the Settings tab has a
|
||||
/// row that re-opens it anytime.
|
||||
///
|
||||
/// The Best Value page itself lives in `BestValueIntroContent`, because the same
|
||||
/// page is also shown to EXISTING users (once per announcement version, by
|
||||
/// `BestValueIntroView`) — one source for its copy, controls and rules, so the
|
||||
/// two can never drift apart.
|
||||
struct OnboardingView: View {
|
||||
var onFinish: () -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var prompter = OnboardingPermissionPrompter()
|
||||
@State private var page = 0
|
||||
@State private var page = OnboardingView.initialPage()
|
||||
/// Best Value's own copy of the stored settings. Loaded so a replay starts
|
||||
/// from what the user already has; the shared page view saves on every
|
||||
/// change — the same immediate-persist rule as Settings — so a swipe past
|
||||
/// the page cannot lose a choice the user has already made.
|
||||
@State private var fuelSavings = OnboardingView.initialFuelSavings()
|
||||
|
||||
private let totalPages = 5
|
||||
private static let totalPages = 6
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -23,7 +35,7 @@ struct OnboardingView: View {
|
||||
HStack {
|
||||
Spacer()
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0..<totalPages, id: \.self) { index in
|
||||
ForEach(0..<Self.totalPages, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(index == page ? Color.accentColor : Color.gray.opacity(0.25))
|
||||
.frame(width: 8, height: 8)
|
||||
@@ -39,7 +51,8 @@ struct OnboardingView: View {
|
||||
locationPage.tag(1)
|
||||
notificationsPage.tag(2)
|
||||
dataPage.tag(3)
|
||||
donePage.tag(4)
|
||||
bestValuePage.tag(4)
|
||||
donePage.tag(5)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
|
||||
@@ -85,7 +98,7 @@ struct OnboardingView: View {
|
||||
private var welcomePage: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
ZStack {
|
||||
AnimatedOnboardingIcon(tint: .accentColor, showsHalo: false) {
|
||||
// The app icon itself, with the standard iOS rounded-corner
|
||||
// treatment (~22% of the size), so onboarding matches the
|
||||
// Home Screen.
|
||||
@@ -95,25 +108,26 @@ struct OnboardingView: View {
|
||||
.frame(width: 96, height: 96)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 21, style: .continuous))
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Text("Welcome to FuelBoard")
|
||||
Text(String(localized: "Welcome to\nFuelBoard", comment: "Onboarding welcome title"))
|
||||
.font(.largeTitle.bold())
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data.")
|
||||
Text(String(localized: "The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data.", comment: "Onboarding welcome subtitle"))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.top, 12)
|
||||
|
||||
featureRow(icon: "globe.europe.africa.fill", text: "UK-wide prices — 8,000+ stations, updated twice a day")
|
||||
featureRow(icon: "scope", text: "Cheapest within your chosen radius, or closest station first")
|
||||
featureRow(icon: "star.fill", text: "Favourites with instant price comparison")
|
||||
featureRow(icon: "chart.bar.fill", text: "Green/amber/red rating vs the best nearby price")
|
||||
featureRow(icon: "square.grid.2x2.fill", text: "Home-screen widget showing the cheapest nearby")
|
||||
featureRow(icon: "bell.fill", text: "Alerts when you approach the cheapest station")
|
||||
featureRow(icon: "map.fill", title: String(localized: "Compare fuel prices near you", comment: "Onboarding feature row title"), subtitle: String(localized: "Over 8,000 stations across Great Britain, with prices updated daily.", comment: "Onboarding feature row subtitle"))
|
||||
|
||||
featureRow(icon: "chart.line.uptrend.xyaxis", title: String(localized: "See how much you could save", comment: "Onboarding feature row title"), subtitle: String(localized: "Compare fill-up costs and potential savings at different stations.", comment: "Onboarding feature row subtitle"))
|
||||
|
||||
featureRow(icon: "car.fill", title: String(localized: "Know if it's worth the drive", comment: "Onboarding feature row title"), subtitle: String(localized: "Factor in your journey and fuel costs to find the best value.", comment: "Onboarding feature row subtitle"))
|
||||
|
||||
featureRow(icon: "star.fill", title: String(localized: "Keep your favourites close", comment: "Onboarding feature row title"), subtitle: String(localized: "Save stations and quickly compare their latest prices.", comment: "Onboarding feature row subtitle"))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
@@ -122,18 +136,17 @@ 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)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
|
||||
Text("Prices near you")
|
||||
Text(String(localized: "Prices near you", comment: "Onboarding location permission title"))
|
||||
.font(.largeTitle.bold())
|
||||
|
||||
Text("FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device.")
|
||||
Text(String(localized: "FuelBoard uses your location to show prices around you and rank stations by distance. Your position never leaves the device.", comment: "Onboarding location permission explanation"))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -143,7 +156,7 @@ struct OnboardingView: View {
|
||||
permissionStatusLabel(
|
||||
granted: prompter.locationGranted,
|
||||
denied: prompter.locationDenied,
|
||||
deniedText: "Location was denied. You can still browse all stations, but \"nearest\" sorting needs it."
|
||||
deniedText: String(localized: "Location was denied. You can still browse all stations, but \"closest\" sorting needs it.", comment: "Onboarding location denied text")
|
||||
)
|
||||
.padding(.top, 24)
|
||||
|
||||
@@ -154,18 +167,17 @@ 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)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
|
||||
Text("Cheapest-station alerts")
|
||||
Text(String(localized: "Cheapest-station alerts", comment: "Onboarding notifications title"))
|
||||
.font(.largeTitle.bold())
|
||||
|
||||
Text("FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab.")
|
||||
Text(String(localized: "FuelBoard can notify you when you're approaching the cheapest station in your alert radius — even with the app closed. You can switch this off in the Alerts tab.", comment: "Onboarding notifications explanation"))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -175,7 +187,7 @@ struct OnboardingView: View {
|
||||
permissionStatusLabel(
|
||||
granted: prompter.notificationsGranted,
|
||||
denied: prompter.notificationsDenied,
|
||||
deniedText: "Notifications were denied — you can still use FuelBoard, just without alert banners."
|
||||
deniedText: String(localized: "Notifications were denied — you can still use FuelBoard, just without alert banners.", comment: "Onboarding notifications denied text")
|
||||
)
|
||||
.padding(.top, 24)
|
||||
|
||||
@@ -186,18 +198,17 @@ 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)
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
|
||||
Text("Prices, ready when you are")
|
||||
Text(String(localized: "Prices, ready when you are", comment: "Onboarding data title"))
|
||||
.font(.largeTitle.bold())
|
||||
|
||||
Text("FuelBoard downloads the latest prices from the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed.")
|
||||
Text(String(localized: "FuelBoard downloads the latest prices from the internet automatically — the full UK dataset, refreshed twice a day. No extra permissions needed.", comment: "Onboarding data explanation"))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -208,6 +219,12 @@ struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Best Value page — the shared body, so the first-run page and the
|
||||
/// announcement existing users see can never drift apart.
|
||||
private var bestValuePage: some View {
|
||||
BestValueIntroContent(settings: $fuelSavings)
|
||||
}
|
||||
|
||||
private var donePage: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
@@ -221,10 +238,10 @@ struct OnboardingView: View {
|
||||
}
|
||||
.padding(.bottom, 28)
|
||||
|
||||
Text("You're all set")
|
||||
Text(String(localized: "You're all set", comment: "Onboarding final title"))
|
||||
.font(.largeTitle.bold())
|
||||
|
||||
Text("Find your cheapest fuel, add favourites, drop the widget on your Home Screen, and let alerts point you to the best price nearby.")
|
||||
Text(String(localized: "Find your cheapest fuel, add favourites, drop the widget on your Home Screen, and let alerts point you to the best price nearby.", comment: "Onboarding final explanation"))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -241,63 +258,70 @@ struct OnboardingView: View {
|
||||
private var bottomAction: some View {
|
||||
switch page {
|
||||
case 0:
|
||||
primaryButton("Continue") { page = 1 }
|
||||
primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) { page = 1 }
|
||||
case 1:
|
||||
primaryButton(
|
||||
prompter.locationDenied ? "Open Settings" : (prompter.locationGranted ? "Continue" : "Allow Location Access")
|
||||
) {
|
||||
primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) {
|
||||
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 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
|
||||
primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) {
|
||||
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.
|
||||
primaryButton("Continue") { page = 4 }
|
||||
primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) { page = 4 }
|
||||
case 4:
|
||||
// Best Value: the settings were saved when the toggle moved, so a
|
||||
// swipe past this page keeps the choice.
|
||||
primaryButton(BestValueIntroContent.primaryTitle) { page = 5 }
|
||||
default:
|
||||
primaryButton("Start Using FuelBoard") { finish() }
|
||||
primaryButton(String(localized: "Start Using FuelBoard", comment: "Finish onboarding button")) { finish() }
|
||||
}
|
||||
}
|
||||
|
||||
private func primaryButton(_ title: LocalizedStringKey, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
private func primaryButton(_ title: String, action: @escaping () -> Void) -> some View {
|
||||
OnboardingPrimaryButton(title: title, action: action)
|
||||
}
|
||||
|
||||
private func featureRow(icon: String, text: LocalizedStringKey) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
private func featureRow(icon: String, title: String, subtitle: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.frame(width: 32)
|
||||
Text(text)
|
||||
.font(.subheadline)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.bold())
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 36)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: LocalizedStringKey) -> some View {
|
||||
private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: String) -> some View {
|
||||
Group {
|
||||
if granted {
|
||||
Label("Allowed", systemImage: "checkmark.circle.fill")
|
||||
Label(String(localized: "Allowed", comment: "Permission granted status"), systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
} else if denied {
|
||||
Text(deniedText)
|
||||
@@ -306,7 +330,7 @@ struct OnboardingView: View {
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
} else {
|
||||
Text("The system prompt will appear next.")
|
||||
Text(String(localized: "The system prompt will appear next.", comment: "Permission prompt pending status"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -321,9 +345,146 @@ struct OnboardingView: View {
|
||||
}
|
||||
|
||||
private func finish() {
|
||||
// Anyone who goes through the whole of onboarding has now seen the Best
|
||||
// Value page — mark the announcement seen so they don't immediately get
|
||||
// the standalone one aimed at existing users.
|
||||
FuelStore.markBestValueIntroSeen()
|
||||
FuelStore.saveHasCompletedOnboarding(true)
|
||||
onFinish()
|
||||
}
|
||||
|
||||
// MARK: - QA hooks
|
||||
|
||||
/// DEBUG-only launch args in the same spirit as ContentView's `-tab` and
|
||||
/// `-skipOnboarding`, both no-ops in release:
|
||||
///
|
||||
/// - `-onboardingPage 4` opens straight on a page, so the screenshot harness
|
||||
/// can photograph a page it cannot swipe or tap its way to.
|
||||
/// - `-onboardingBestValue on|off` forces the Best Value page's choice, so
|
||||
/// both of its states can be captured without driving the toggle.
|
||||
private static func initialPage() -> Int {
|
||||
#if DEBUG
|
||||
let args = ProcessInfo.processInfo.arguments
|
||||
if let index = args.firstIndex(of: "-onboardingPage"),
|
||||
index + 1 < args.count,
|
||||
let value = Int(args[index + 1]),
|
||||
(0..<totalPages).contains(value) {
|
||||
return value
|
||||
}
|
||||
#endif
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func initialFuelSavings() -> FuelSavingsSettings {
|
||||
var settings = FuelStore.loadFuelSavingsSettings()
|
||||
#if DEBUG
|
||||
let args = ProcessInfo.processInfo.arguments
|
||||
if let index = args.firstIndex(of: "-onboardingBestValue"), index + 1 < args.count {
|
||||
settings.enabled = args[index + 1].lowercased() == "on"
|
||||
}
|
||||
#endif
|
||||
return settings
|
||||
}
|
||||
}
|
||||
|
||||
/// The animated badge behind each page's icon. Internal rather than private
|
||||
/// because `BestValueIntroContent` lives in its own file and uses it too.
|
||||
struct AnimatedOnboardingIcon<Content: View>: View {
|
||||
enum Motion {
|
||||
case gentle
|
||||
case locationArrow
|
||||
case alarmBell
|
||||
case downloadArrow
|
||||
}
|
||||
|
||||
let tint: Color
|
||||
var motion: Motion = .gentle
|
||||
/// The soft circle behind the glyph. The welcome page turns it off: it shows
|
||||
/// the app icon itself and reads better with no container around it.
|
||||
var showsHalo: Bool = true
|
||||
@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 {
|
||||
if showsHalo {
|
||||
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
|
||||
|
||||
@@ -79,10 +79,14 @@ struct TieChoice {
|
||||
/// entering the winner's circle — not at the forecourt.
|
||||
@MainActor
|
||||
final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate, UNUserNotificationCenterDelegate {
|
||||
private static let favouriteDropThresholdPence: Double = 1.0
|
||||
@Published var monitoredStationIDs: [String] = []
|
||||
@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?
|
||||
@@ -107,6 +111,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
private var favourites: [FuelStation] = []
|
||||
private var fuel: FuelType = .e10
|
||||
private var radiusKM: Double = 3.0
|
||||
private var alertMode: AlertMode = .cheapest
|
||||
private var bestValueThresholdGBP: Double = 1.0
|
||||
private var fuelSavingsSettings: FuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
||||
private var fuelSavingsAmount: FuelSavingsFillAmount = FuelStore.loadFuelSavingsSettings().preferredAmount
|
||||
private var lastNotified: [String: Date] = [:] // dedup per station
|
||||
private var enabled = false
|
||||
/// Dynamically registered categories for tie-choice notifications. Kept so
|
||||
@@ -131,6 +139,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
radiusKM = FuelStore.effectiveAlertsRadiusKM(
|
||||
followsSearch: FuelStore.loadAlertsFollowsSearch(),
|
||||
manualKM: FuelStore.loadAlertsRadius())
|
||||
alertMode = FuelStore.loadAlertsMode()
|
||||
bestValueThresholdGBP = FuelStore.loadAlertsBestValueThresholdGBP()
|
||||
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
||||
fuelSavingsAmount = fuelSavingsSettings.preferredAmount
|
||||
stations = FuelStore.loadStations()
|
||||
favourites = FuelStore.loadFavourites().filter { $0.fuel == fuel }.map(\.station)
|
||||
}
|
||||
@@ -139,7 +151,16 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
/// Favourites are fuel-scoped entries; only entries for the monitored fuel
|
||||
/// get priority slots (a Diesel favourite must not grab a slot while
|
||||
/// monitoring Unleaded).
|
||||
func update(stations: [FuelStation], favourites: [FavouriteEntry], fuel: FuelType, radiusKM: Double) {
|
||||
func update(
|
||||
stations: [FuelStation],
|
||||
favourites: [FavouriteEntry],
|
||||
fuel: FuelType,
|
||||
radiusKM: Double,
|
||||
alertMode: AlertMode = .cheapest,
|
||||
bestValueThresholdGBP: Double = 1.0,
|
||||
fuelSavingsSettings: FuelSavingsSettings = .defaults,
|
||||
fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
|
||||
) {
|
||||
// Fall back to the shared cache when called before the first fetch
|
||||
// completes (launch, background region-event wake).
|
||||
self.stations = stations.isEmpty ? FuelStore.loadStations() : stations
|
||||
@@ -148,6 +169,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
: favourites.filter { $0.fuel == fuel }.map(\.station)
|
||||
self.fuel = fuel
|
||||
self.radiusKM = radiusKM
|
||||
self.alertMode = alertMode
|
||||
self.bestValueThresholdGBP = bestValueThresholdGBP
|
||||
self.fuelSavingsSettings = fuelSavingsSettings
|
||||
self.fuelSavingsAmount = fuelSavingsAmount
|
||||
|
||||
refreshDebugStatus()
|
||||
|
||||
@@ -165,18 +190,37 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
// Debug section only shows the CURRENT region problem.
|
||||
lastRegionError = nil
|
||||
|
||||
// Favourites first (guaranteed slots), then closest stations, max 18.
|
||||
// `self.favourites` is already filtered to the monitored fuel.
|
||||
// Favourites first (guaranteed slots). For Best Value mode, spend the
|
||||
// remaining iOS region slots on likely savings winners before falling
|
||||
// back to closest stations; the actual notification still rechecks the
|
||||
// winner after the geofence wakes the app.
|
||||
var candidates: [FuelStation] = self.favourites
|
||||
let favIDs = Set(self.favourites.map(\.id))
|
||||
let location = FuelStore.loadLocation()
|
||||
let others = stations
|
||||
.filter { !favIDs.contains($0.id) }
|
||||
.sorted { lhs, rhs in
|
||||
guard let location else { return false }
|
||||
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
|
||||
rhs.distanceKM(to: location.lat, lng2: location.lng)
|
||||
let eligibleOthers = stations.filter { !favIDs.contains($0.id) && $0.prices[fuel] != nil }
|
||||
let others: [FuelStation]
|
||||
if alertMode == .bestValue,
|
||||
fuelSavingsSettings.isConfigured,
|
||||
let location {
|
||||
let inRadius = eligibleOthers.filter {
|
||||
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
|
||||
userLat: location.lat, userLng: location.lng)
|
||||
}
|
||||
others = FuelSavingsEstimator.ranked(
|
||||
inRadius,
|
||||
fuel: fuel,
|
||||
baseline: savingsBaseline(in: inRadius, location: location),
|
||||
settings: fuelSavingsSettings,
|
||||
amount: fuelSavingsAmount,
|
||||
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
|
||||
)
|
||||
} else {
|
||||
others = eligibleOthers.sorted { lhs, rhs in
|
||||
guard let location else { return false }
|
||||
return FuelStore.displayDistanceKM(station: lhs, userLat: location.lat, userLng: location.lng) <
|
||||
FuelStore.displayDistanceKM(station: rhs, userLat: location.lat, userLng: location.lng)
|
||||
}
|
||||
}
|
||||
candidates.append(contentsOf: others)
|
||||
|
||||
var registered: [String] = []
|
||||
@@ -218,6 +262,73 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
|
||||
var isEnabled: Bool { enabled }
|
||||
|
||||
private func savingsBaseline(in stations: [FuelStation], location: Coordinate) -> FuelStation? {
|
||||
FuelSavingsEstimator.baselineStation(
|
||||
stations,
|
||||
fuel: fuel,
|
||||
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: location) }
|
||||
)
|
||||
}
|
||||
|
||||
private func savingsJourneyMiles(for station: FuelStation, location: Coordinate) -> FuelSavingsJourney? {
|
||||
if let roadMeters = FuelStore.roadJourneyMeters(for: station, userLat: location.lat, userLng: location.lng) {
|
||||
let confidence: JourneyConfidence = FuelStore.hasMeasuredReturnRoadDistance(
|
||||
for: station, userLat: location.lat, userLng: location.lng
|
||||
) ? .confirmed : .estimated
|
||||
return FuelSavingsJourney(
|
||||
miles: roadMeters / FuelSavingsCalculator.metresPerMile,
|
||||
basis: .roadRoute,
|
||||
confidence: confidence
|
||||
)
|
||||
}
|
||||
return FuelSavingsJourney(
|
||||
miles: FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng) /
|
||||
FuelSavingsCalculator.kilometresPerMile * 2,
|
||||
basis: .straightLine,
|
||||
confidence: .estimated
|
||||
)
|
||||
}
|
||||
|
||||
/// Compares the latest refreshed favourites against the persisted cheapest-
|
||||
/// favourite baseline for one fuel and schedules a notification when the
|
||||
/// winner changes or the current winner drops by a meaningful amount.
|
||||
func evaluateFavouritePriceDropAlert(
|
||||
favourites: [FavouriteEntry],
|
||||
previousSnapshots: [FuelType: FavouriteAlertSnapshot],
|
||||
monitoredFuel: FuelType,
|
||||
enabled: Bool
|
||||
) {
|
||||
var snapshots = previousSnapshots
|
||||
guard let current = FuelStore.cheapestFavourite(in: favourites, fuel: monitoredFuel),
|
||||
let currentPrice = current.station.prices[monitoredFuel] else {
|
||||
snapshots.removeValue(forKey: monitoredFuel)
|
||||
FuelStore.saveFavouriteAlertSnapshots(snapshots)
|
||||
return
|
||||
}
|
||||
|
||||
let currentSnapshot = FavouriteAlertSnapshot(
|
||||
fuel: monitoredFuel,
|
||||
stationID: current.station.id,
|
||||
stationName: current.station.name,
|
||||
price: currentPrice
|
||||
)
|
||||
|
||||
defer {
|
||||
snapshots[monitoredFuel] = currentSnapshot
|
||||
FuelStore.saveFavouriteAlertSnapshots(snapshots)
|
||||
}
|
||||
|
||||
guard enabled else { return }
|
||||
guard let previous = previousSnapshots[monitoredFuel] else { return }
|
||||
|
||||
let winnerChanged = previous.stationID != currentSnapshot.stationID
|
||||
let priceDropped = previous.stationID == currentSnapshot.stationID
|
||||
&& currentSnapshot.price <= previous.price - Self.favouriteDropThresholdPence
|
||||
|
||||
guard winnerChanged || priceDropped else { return }
|
||||
fireFavouriteDropAlert(current: current, previous: previous, price: currentPrice, winnerChanged: winnerChanged)
|
||||
}
|
||||
|
||||
/// Records a stage in the live alert trace, newest first, capped at 8.
|
||||
private func logAlert(_ kind: AlertLogEntry.Kind, _ text: String) {
|
||||
alertLog.insert(AlertLogEntry(date: Date(), kind: kind, text: text), at: 0)
|
||||
@@ -272,12 +383,15 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
return
|
||||
}
|
||||
let sellers = stations.filter { $0.prices[fuel] != nil }
|
||||
// Display distance, so the "N in range" count here matches the list the
|
||||
// user is actually looking at.
|
||||
let inRange = sellers.filter {
|
||||
$0.distanceKM(to: fix.coordinate.lat, lng2: fix.coordinate.lng) <= radiusKM
|
||||
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
|
||||
userLat: fix.coordinate.lat, userLng: fix.coordinate.lng)
|
||||
}
|
||||
let cheapest = inRange.min { ($0.prices[fuel] ?? .infinity) < ($1.prices[fuel] ?? .infinity) }
|
||||
let cheapestDistance = cheapest.map {
|
||||
$0.distanceKM(to: fix.coordinate.lat, lng2: fix.coordinate.lng)
|
||||
FuelStore.displayDistanceKM(station: $0, userLat: fix.coordinate.lat, userLng: fix.coordinate.lng)
|
||||
}
|
||||
debugStatus = DebugLocationStatus(
|
||||
coordinate: fix.coordinate,
|
||||
@@ -391,25 +505,13 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
$0.prices[fuel] != nil &&
|
||||
$0.distanceKM(to: station.lat, lng2: station.lng) <= radiusKM
|
||||
}
|
||||
guard let cheapest = withinRadius.min(by: { $0.prices[fuel]! < $1.prices[fuel]! }) else {
|
||||
logAlert(.gate, "\(station.name) — no \(fuel.displayName.lowercased()) sellers within \(String(format: "%.1f", radiusKM)) km after fresh fetch")
|
||||
return
|
||||
}
|
||||
guard cheapest.id == station.id else {
|
||||
logAlert(.gate, "\(station.name) — not cheapest: \(cheapest.name) at \(String(format: "%.1fp", cheapest.prices[fuel]!)) is cheaper within the radius")
|
||||
return
|
||||
}
|
||||
// withinRadius guarantees the fuel exists on every candidate.
|
||||
guard let price = cheapest.prices[fuel] else { return }
|
||||
|
||||
// Ties: all stations in radius at the same cheapest price.
|
||||
let tied = FuelStore.tiedStations(
|
||||
in: withinRadius, fuel: fuel, price: price,
|
||||
fromLat: station.lat, lng: station.lng
|
||||
)
|
||||
|
||||
fireAlert(for: cheapest, price: price, ties: tied)
|
||||
lastNotified[stationID] = Date()
|
||||
switch alertMode {
|
||||
case .cheapest:
|
||||
handleCheapestEntry(station: station, withinRadius: withinRadius)
|
||||
case .bestValue:
|
||||
handleBestValueEntry(station: station, withinRadius: withinRadius)
|
||||
}
|
||||
} catch {
|
||||
// Trace the silent catch — a relay failure drops the alert
|
||||
// with no banner, which previously looked like "no alert".
|
||||
@@ -418,6 +520,65 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCheapestEntry(station: FuelStation, withinRadius: [FuelStation]) {
|
||||
guard let cheapest = withinRadius.min(by: { $0.prices[fuel]! < $1.prices[fuel]! }) else {
|
||||
logAlert(.gate, "\(station.name) — no \(fuel.displayName.lowercased()) sellers within \(String(format: "%.1f", radiusKM)) km after fresh fetch")
|
||||
return
|
||||
}
|
||||
guard cheapest.id == station.id else {
|
||||
logAlert(.gate, "\(station.name) — not cheapest: \(cheapest.name) at \(String(format: "%.1fp", cheapest.prices[fuel]!)) is cheaper within the radius")
|
||||
return
|
||||
}
|
||||
// withinRadius guarantees the fuel exists on every candidate.
|
||||
guard let price = cheapest.prices[fuel] else { return }
|
||||
|
||||
// Ties: all stations in radius at the same cheapest price.
|
||||
let tied = FuelStore.tiedStations(
|
||||
in: withinRadius, fuel: fuel, price: price,
|
||||
fromLat: station.lat, lng: station.lng
|
||||
)
|
||||
|
||||
fireAlert(for: cheapest, price: price, ties: tied)
|
||||
lastNotified[station.id] = Date()
|
||||
}
|
||||
|
||||
private func handleBestValueEntry(station: FuelStation, withinRadius: [FuelStation]) {
|
||||
guard fuelSavingsSettings.isConfigured else {
|
||||
logAlert(.gate, "\(station.name) — Best Value not configured")
|
||||
return
|
||||
}
|
||||
guard let candidate = bestValueCandidate(in: withinRadius, around: station) else {
|
||||
logAlert(.gate, "\(station.name) — no Best Value candidate clears \(FuelSavingsFormat.money(bestValueThresholdGBP))")
|
||||
return
|
||||
}
|
||||
guard candidate.station.id == station.id else {
|
||||
logAlert(.gate, "\(station.name) — not Best Value: \(candidate.station.name) is \(candidate.result.headline())")
|
||||
return
|
||||
}
|
||||
fireBestValueAlert(for: candidate.station, result: candidate.result)
|
||||
lastNotified[station.id] = Date()
|
||||
}
|
||||
|
||||
private func bestValueCandidate(in stations: [FuelStation], around station: FuelStation) -> BestValueAlertCandidate? {
|
||||
let origin = Coordinate(lat: station.lat, lng: station.lng)
|
||||
let threshold = bestValueThresholdGBP
|
||||
let extraMargin = 0.50
|
||||
let candidate = FuelSavingsCalculator.bestValueAlertCandidate(
|
||||
stations: stations,
|
||||
fuel: fuel,
|
||||
settings: fuelSavingsSettings,
|
||||
amount: fuelSavingsAmount,
|
||||
minimumNetSavingGBP: threshold,
|
||||
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: origin) }
|
||||
)
|
||||
guard let candidate else { return nil }
|
||||
if candidate.result.journeyConfidence.isEstimated,
|
||||
candidate.result.netSavingGBP < threshold + extraMargin {
|
||||
return nil
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func fireAlert(for station: FuelStation, price: Double, ties: [FuelStation] = []) {
|
||||
let brand = station.brand.isEmpty ? station.name : station.brand
|
||||
let unit = FuelStore.loadDistanceUnit()
|
||||
@@ -452,6 +613,42 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away"
|
||||
}
|
||||
|
||||
private func fireBestValueAlert(for station: FuelStation, result: FuelSavingsResult) {
|
||||
let brand = station.brand.isEmpty ? station.name : station.brand
|
||||
let content = UNMutableNotificationContent()
|
||||
content.sound = .default
|
||||
content.title = "Best Value \(fuel.displayName) nearby: \(brand)"
|
||||
content.body = "\(station.name): \(result.headline()). Tap for directions."
|
||||
addAlertRequest(content: content, station: station)
|
||||
logAlert(.fired, "Best Value alert scheduled — \(brand) · \(result.headline())")
|
||||
lastAlert = "Best Value · \(brand) · \(result.headline())"
|
||||
}
|
||||
|
||||
private func fireFavouriteDropAlert(
|
||||
current: FavouriteEntry,
|
||||
previous: FavouriteAlertSnapshot,
|
||||
price: Double,
|
||||
winnerChanged: Bool
|
||||
) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.sound = .default
|
||||
let fuel = current.fuel
|
||||
let previousPriceText = String(format: "%.1fp", previous.price)
|
||||
let currentPriceText = String(format: "%.1fp", price)
|
||||
|
||||
if winnerChanged {
|
||||
content.title = "New cheapest \(fuel.displayName.lowercased()) favourite"
|
||||
content.body = "\(current.station.name) is now your cheapest favourite at \(currentPriceText), ahead of \(previous.stationName)."
|
||||
} else {
|
||||
content.title = "Cheapest favourite just dropped"
|
||||
content.body = "\(current.station.name) fell from \(previousPriceText) to \(currentPriceText) for \(fuel.displayName.lowercased())."
|
||||
}
|
||||
|
||||
addAlertRequest(content: content, station: current.station)
|
||||
logAlert(.fired, "favourite alert scheduled — \(current.station.name) · \(currentPriceText)")
|
||||
lastAlert = "Favourite · \(current.station.name) · \(currentPriceText)"
|
||||
}
|
||||
|
||||
/// Adds a notification whose tap opens directions to `station`. The
|
||||
/// station's coordinates ride in `userInfo` so the tap handler can route
|
||||
/// to Apple Maps (or an in-app fallback), and a map snapshot with a pin is
|
||||
@@ -597,6 +794,15 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
/// the geofence itself. Uses the monitor's live state (fuel, radius,
|
||||
/// stations) so the notification reflects the Alerts-tab configuration.
|
||||
func sendTestNotification() {
|
||||
switch alertMode {
|
||||
case .cheapest:
|
||||
sendCheapestTestNotification()
|
||||
case .bestValue:
|
||||
sendBestValueTestNotification()
|
||||
}
|
||||
}
|
||||
|
||||
private func sendCheapestTestNotification() {
|
||||
let unit = FuelStore.loadDistanceUnit()
|
||||
let location = FuelStore.loadLocation()
|
||||
let sellers = stations.filter { $0.prices[fuel] != nil }
|
||||
@@ -605,7 +811,8 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
// identical to handleEntry's withinRadius logic.
|
||||
let inRadius = sellers.filter { station in
|
||||
guard let location else { return true }
|
||||
return station.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
|
||||
return FuelStore.isWithinDisplayRadius(station: station, radiusKM: radiusKM,
|
||||
userLat: location.lat, userLng: location.lng)
|
||||
}
|
||||
let candidate = inRadius.min { ($0.prices[fuel] ?? .infinity) < ($1.prices[fuel] ?? .infinity) }
|
||||
|
||||
@@ -672,6 +879,70 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
||||
}
|
||||
}
|
||||
|
||||
private func sendBestValueTestNotification() {
|
||||
let unit = FuelStore.loadDistanceUnit()
|
||||
let location = FuelStore.loadLocation()
|
||||
let sellers = stations.filter { $0.prices[fuel] != nil }
|
||||
let inRadius = sellers.filter { station in
|
||||
guard let location else { return true }
|
||||
return FuelStore.isWithinDisplayRadius(station: station, radiusKM: radiusKM,
|
||||
userLat: location.lat, userLng: location.lng)
|
||||
}
|
||||
let origin = location ?? inRadius.first.map { Coordinate(lat: $0.lat, lng: $0.lng) }
|
||||
let candidate: BestValueAlertCandidate? = {
|
||||
guard let origin else { return nil }
|
||||
return FuelSavingsCalculator.bestValueAlertCandidate(
|
||||
stations: inRadius,
|
||||
fuel: fuel,
|
||||
settings: fuelSavingsSettings,
|
||||
amount: fuelSavingsAmount,
|
||||
minimumNetSavingGBP: bestValueThresholdGBP,
|
||||
journeyMiles: { [self] station in savingsJourneyMiles(for: station, location: origin) }
|
||||
)
|
||||
}()
|
||||
guard fuelSavingsSettings.isConfigured else {
|
||||
sendNoCandidateTestNotification(
|
||||
title: "FuelBoard Best Value test",
|
||||
body: "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.",
|
||||
result: "Best Value not configured"
|
||||
)
|
||||
return
|
||||
}
|
||||
guard let candidate else {
|
||||
let body = sellers.isEmpty
|
||||
? "No station data loaded yet — open the Stations tab first, then retry."
|
||||
: "No Best Value candidate above \(FuelSavingsFormat.money(bestValueThresholdGBP)) within \(unit.format(radiusKM))."
|
||||
sendNoCandidateTestNotification(
|
||||
title: "FuelBoard Best Value test",
|
||||
body: body,
|
||||
result: sellers.isEmpty ? "No stations loaded — open the Stations tab first" : "No Best Value candidate above \(FuelSavingsFormat.money(bestValueThresholdGBP)) within \(unit.format(radiusKM))"
|
||||
)
|
||||
return
|
||||
}
|
||||
let content = UNMutableNotificationContent()
|
||||
content.sound = .default
|
||||
let brand = candidate.station.brand.isEmpty ? candidate.station.name : candidate.station.brand
|
||||
content.title = "Best Value \(fuel.displayName) nearby: \(brand)"
|
||||
content.body = "\(candidate.station.name): \(candidate.result.headline()). Tap for directions."
|
||||
let baseResult = "Best Value · \(brand) · \(candidate.result.headline()) · radius \(unit.format(radiusKM))"
|
||||
lastTestResult = baseResult
|
||||
logAlert(.fired, "Best Value test alert scheduled — \(brand) · \(candidate.result.headline())")
|
||||
addAlertRequest(content: content, station: candidate.station) { status in
|
||||
self.lastTestResult = "\(baseResult) · \(status)"
|
||||
}
|
||||
}
|
||||
|
||||
private func sendNoCandidateTestNotification(title: String, body: String, result: String) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
logAlert(.fired, "test alert scheduled — \(result)")
|
||||
lastTestResult = result
|
||||
}
|
||||
|
||||
/// Fires a notification with NO criteria checks at all — no geofence, no
|
||||
/// cheapest-within-radius, no dedup, no permission gate, no fuel/radius
|
||||
/// coupling. Still carries a REAL station (the nearest one selling the
|
||||
@@ -725,9 +996,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
|
||||
@@ -752,9 +1025,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.
|
||||
@@ -762,6 +1035,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
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
// 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. In
|
||||
// "Accurate distances" mode a pass routes the WHOLE search area (up to
|
||||
// `fillCapacity` stations) so the browse layer can serve routed distances for
|
||||
// every row at once; the mode must also be ON for any pass to run at all.
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
import MapKit
|
||||
import UIKit
|
||||
import WidgetKit
|
||||
|
||||
struct RoadDistanceFillDebugStatus: Codable, Equatable {
|
||||
var state: State = .idle
|
||||
var reason: String = "Waiting"
|
||||
var updatedAt: Date = Date()
|
||||
var originLat: Double?
|
||||
var originLng: Double?
|
||||
var stationPoolCount: Int = 0
|
||||
var priorityCount: Int = 0
|
||||
var candidateCount: Int = 0
|
||||
var routedCount: Int = 0
|
||||
var cachedCount: Int = 0
|
||||
var failedCount: Int = 0
|
||||
var complete: Bool = false
|
||||
var capped: Bool = false
|
||||
var elapsedSeconds: Double?
|
||||
var movedMeters: Double?
|
||||
var throttleSecondsRemaining: Double?
|
||||
|
||||
enum State: String, Codable {
|
||||
case idle
|
||||
case gated
|
||||
case running
|
||||
case completed
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
switch state {
|
||||
case .idle: return "Idle — \(reason)"
|
||||
case .gated: return "Gated — \(reason)"
|
||||
case .running: return "Running — \(reason)"
|
||||
case .completed: return complete ? "Complete — routed set ready" : "Incomplete — still Approx."
|
||||
}
|
||||
}
|
||||
|
||||
static let idle = RoadDistanceFillDebugStatus()
|
||||
}
|
||||
|
||||
enum RoadDistanceService {
|
||||
/// Upper bound on stations routed per pass. Accurate-distances mode routes the
|
||||
/// whole search area in ONE pass, because a 12-per-pass budget could never
|
||||
/// make a list fully routed — that is exactly why two devices (and two
|
||||
/// radii) disagreed about the same station. Capped so a pathological dataset
|
||||
/// can't turn one pass into thousands of route requests; a capped pass stays
|
||||
/// incomplete, and the browse layer keeps showing straight-line + "Approx."
|
||||
/// until a later pass finishes the job. Measured on device: ~3.2 route calls
|
||||
/// per second, so 600 stations (1,200 calls) is ~6 min at the very worst.
|
||||
static let fillCapacity = 600
|
||||
/// How many Worth-the-Drive candidates may claim slots in a pass. Those
|
||||
/// rows are ranked by NET SAVING and are typically FURTHER out than the
|
||||
/// nearest stations — routing only the nearest stations (the old behaviour)
|
||||
/// meant they never got a measured route and stayed on straight-line
|
||||
/// estimates forever.
|
||||
static let priorityCandidates = 6
|
||||
/// 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) once the set is whole.
|
||||
static let throttleMinutes: Double = 10
|
||||
/// Retry window for a set that is still INCOMPLETE — a pass cut short by
|
||||
/// backgrounding the app must be continued promptly, not in ten minutes.
|
||||
static let incompleteRetrySeconds: Double = 30
|
||||
/// Recompute when the user moves more than this (metres) from the last
|
||||
/// source location.
|
||||
static let moveThresholdMeters: Double = 400
|
||||
static private(set) var debugStatus = RoadDistanceFillDebugStatus.idle
|
||||
|
||||
@MainActor
|
||||
static func currentDebugStatus() -> RoadDistanceFillDebugStatus {
|
||||
debugStatus
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func publishDebug(_ status: RoadDistanceFillDebugStatus) {
|
||||
debugStatus = status
|
||||
NotificationCenter.default.post(name: .fuelBoardRoadDistanceDebugUpdated, object: nil)
|
||||
}
|
||||
|
||||
private static func updateDebug(_ status: RoadDistanceFillDebugStatus) async {
|
||||
await MainActor.run { publishDebug(status) }
|
||||
}
|
||||
|
||||
private static func baseDebugStatus(state: RoadDistanceFillDebugStatus.State,
|
||||
reason: String,
|
||||
stations: [FuelStation],
|
||||
lat: Double,
|
||||
lng: Double,
|
||||
priorityCount: Int) -> RoadDistanceFillDebugStatus {
|
||||
RoadDistanceFillDebugStatus(
|
||||
state: state,
|
||||
reason: reason,
|
||||
updatedAt: Date(),
|
||||
originLat: lat,
|
||||
originLng: lng,
|
||||
stationPoolCount: stations.count,
|
||||
priorityCount: priorityCount
|
||||
)
|
||||
}
|
||||
|
||||
/// Refreshes the cached road distances for the in-radius stations around
|
||||
/// `lat`/`lng`. Throttled by time + distance; safe to call on every fix.
|
||||
///
|
||||
/// - Parameter priorityStations: stations to route BEFORE the nearest ones
|
||||
/// (the Worth-the-Drive candidates and the comparison baseline), so the
|
||||
/// numbers the user actually reads are road-routed rather than estimated.
|
||||
static func refreshIfNeeded(stations: [FuelStation],
|
||||
lat: Double,
|
||||
lng: Double,
|
||||
prioritising priorityStations: [FuelStation] = []) async {
|
||||
// "Accurate distances" OFF (the default) means the browse layer never
|
||||
// serves a routed value, so any routing would be pure cost with nothing
|
||||
// visible to show for it: no pass, no requests, no widget reloads.
|
||||
guard FuelStore.accurateDistancesEnabled else {
|
||||
await updateDebug(baseDebugStatus(state: .gated,
|
||||
reason: "Accurate distances off",
|
||||
stations: stations,
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
priorityCount: priorityStations.count))
|
||||
return
|
||||
}
|
||||
// Only fill while the app is on screen. The browse layer is the only
|
||||
// consumer, so a background fill would spend radio and battery routing a
|
||||
// position nothing is displaying — worst case, a long drive.
|
||||
guard await appIsActive() else {
|
||||
await updateDebug(baseDebugStatus(state: .gated,
|
||||
reason: "App not active",
|
||||
stations: stations,
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
priorityCount: priorityStations.count))
|
||||
return
|
||||
}
|
||||
guard await refreshGate.begin() else {
|
||||
await updateDebug(baseDebugStatus(state: .gated,
|
||||
reason: "Fill already running",
|
||||
stations: stations,
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
priorityCount: priorityStations.count))
|
||||
return
|
||||
}
|
||||
defer { Task { await refreshGate.end() } }
|
||||
|
||||
guard !stations.isEmpty else {
|
||||
await updateDebug(baseDebugStatus(state: .gated,
|
||||
reason: "No stations loaded",
|
||||
stations: stations,
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
priorityCount: priorityStations.count))
|
||||
return
|
||||
}
|
||||
|
||||
let existing = FuelStore.loadRoadDistances()
|
||||
// Entries can only be merged when they were routed from (near enough)
|
||||
// this same origin — the read path rejects anything else anyway.
|
||||
let originCompatible = existing.map {
|
||||
haversineMeters($0.sourceLat, $0.sourceLng, lat, lng) < moveThresholdMeters
|
||||
} ?? false
|
||||
|
||||
// Throttle: keep cached values when fresh and the user hasn't moved far.
|
||||
// `FB_ROUTE_REFRESH=1` forces a pass (DEBUG verification hook).
|
||||
#if DEBUG
|
||||
let forcePass = ProcessInfo.processInfo.environment["FB_ROUTE_REFRESH"] == "1"
|
||||
#else
|
||||
let forcePass = false
|
||||
#endif
|
||||
if !forcePass, let cache = existing {
|
||||
let elapsed = Date().timeIntervalSince1970 - cache.updatedAt
|
||||
let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng)
|
||||
// An INCOMPLETE set still has work to do, so it is not throttled for
|
||||
// the full ten minutes. The browse layer refuses to serve a partial
|
||||
// set (every row reads straight-line + "Approx." until the set is
|
||||
// whole), and a pass is routinely cut short by backgrounding the app
|
||||
// — so a ten-minute throttle on an incomplete set is what made
|
||||
// "Approx." look permanent: reopen the app inside the window and the
|
||||
// pass that would finish the job never even started, and refreshing
|
||||
// the prices could not help. Retry soon instead.
|
||||
let incomplete = cache.complete != true
|
||||
let throttleWindow = incomplete ? incompleteRetrySeconds : throttleMinutes * 60
|
||||
if elapsed < throttleWindow && movedMeters < moveThresholdMeters {
|
||||
var status = baseDebugStatus(state: .gated,
|
||||
reason: incomplete ? "Throttled — incomplete set, retrying"
|
||||
: "Throttled",
|
||||
stations: stations,
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
priorityCount: priorityStations.count)
|
||||
status.cachedCount = cache.entries.count
|
||||
status.complete = !incomplete
|
||||
status.movedMeters = movedMeters
|
||||
status.throttleSecondsRemaining = throttleWindow - elapsed
|
||||
if incomplete,
|
||||
await repairCompletenessIfNothingLeftToRoute(cache, stations: stations,
|
||||
lat: lat, lng: lng) {
|
||||
status.complete = true
|
||||
status.reason = "Throttled — stale incomplete flag repaired"
|
||||
}
|
||||
await updateDebug(status)
|
||||
if status.complete {
|
||||
await notifyDistanceSurfaces()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Candidates: Worth-the-Drive priorities first, then the nearest
|
||||
// stations. Stations that already have a route from this origin are
|
||||
// skipped so the per-pass budget goes on the ones still missing one.
|
||||
var candidates: [FuelStation] = []
|
||||
var seen = Set<String>()
|
||||
func consider(_ station: FuelStation) {
|
||||
guard candidates.count < fillCapacity else { return }
|
||||
guard station.distanceKM(to: lat, lng2: lng) <= maxRadiusKM else { return }
|
||||
guard seen.insert(station.id).inserted else { return }
|
||||
guard !FuelStore.hasRoadDistance(for: station, userLat: lat, userLng: lng) else { return }
|
||||
candidates.append(station)
|
||||
}
|
||||
for station in priorityStations.prefix(priorityCandidates) { consider(station) }
|
||||
let nearest = stations.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
|
||||
for station in nearest {
|
||||
if candidates.count >= fillCapacity { break }
|
||||
consider(station)
|
||||
}
|
||||
|
||||
guard !candidates.isEmpty else {
|
||||
var status = baseDebugStatus(state: .gated,
|
||||
reason: "No unrouted candidates",
|
||||
stations: stations,
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
priorityCount: priorityStations.count)
|
||||
status.cachedCount = existing?.entries.count ?? 0
|
||||
var complete = existing?.complete == true && FuelStore.accurateDistancesReady(userLat: lat, userLng: lng)
|
||||
if !complete, let existing,
|
||||
await repairCompletenessIfNothingLeftToRoute(existing, stations: stations, lat: lat, lng: lng) {
|
||||
complete = true
|
||||
status.reason = "No unrouted candidates — stale incomplete flag repaired"
|
||||
}
|
||||
status.complete = complete
|
||||
await updateDebug(status)
|
||||
if status.complete {
|
||||
await notifyDistanceSurfaces()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
||||
var entries: [String: CachedRoadDistance] = originCompatible ? (existing?.entries ?? [:]) : [:]
|
||||
|
||||
var running = baseDebugStatus(state: .running,
|
||||
reason: "Routing \(candidates.count) stations",
|
||||
stations: stations,
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
priorityCount: priorityStations.count)
|
||||
running.candidateCount = candidates.count
|
||||
running.cachedCount = entries.count
|
||||
running.capped = candidates.count >= fillCapacity
|
||||
await updateDebug(running)
|
||||
|
||||
let passStart = Date()
|
||||
|
||||
var routedCount = 0
|
||||
for station in candidates {
|
||||
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
||||
async let outward = roadMeters(from: origin, to: dest)
|
||||
async let back = roadMeters(from: dest, to: origin)
|
||||
if let meters = await outward {
|
||||
routedCount += 1
|
||||
// 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).
|
||||
let returnMeters = await back
|
||||
entries[station.id] = CachedRoadDistance(meters: meters,
|
||||
lat: station.lat,
|
||||
lng: station.lng,
|
||||
returnMeters: returnMeters)
|
||||
}
|
||||
}
|
||||
guard entries.count > (originCompatible ? (existing?.entries.count ?? 0) : 0) || !originCompatible else {
|
||||
var status = baseDebugStatus(state: .gated,
|
||||
reason: "Route pass added no distances",
|
||||
stations: stations,
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
priorityCount: priorityStations.count)
|
||||
status.candidateCount = candidates.count
|
||||
status.routedCount = routedCount
|
||||
status.failedCount = candidates.count - routedCount
|
||||
status.cachedCount = entries.count
|
||||
status.elapsedSeconds = Date().timeIntervalSince(passStart)
|
||||
await updateDebug(status)
|
||||
return
|
||||
}
|
||||
|
||||
// A pass is complete when nothing in the search area is left UNRESOLVED:
|
||||
// every station either has a route from this anchor, or was attempted in
|
||||
// THIS pass and Apple Maps returned none. A few pins genuinely have no
|
||||
// route (offshore, deleted forecourt, bad snapshot) and one of those must
|
||||
// never leave the area permanently incomplete — that would pin the whole
|
||||
// list on straight-line estimates forever. Those stations show their
|
||||
// straight line, marked Approx., and the radius filter uses that same
|
||||
// distance, so nothing is ever out of step with what a row displays.
|
||||
let attempted = Set(candidates.map(\.id))
|
||||
let capped = candidates.count >= fillCapacity
|
||||
let unresolved = capped || stations.contains { station in
|
||||
station.distanceKM(to: lat, lng2: lng) <= maxRadiusKM
|
||||
&& !FuelStore.hasRoadDistance(for: station, userLat: lat, userLng: lng)
|
||||
&& !attempted.contains(station.id)
|
||||
}
|
||||
FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries,
|
||||
complete: !unresolved)
|
||||
var completed = baseDebugStatus(state: .completed,
|
||||
reason: !unresolved ? "Fill complete" : (capped ? "Capacity cap hit" : "Unresolved stations remain"),
|
||||
stations: stations,
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
priorityCount: priorityStations.count)
|
||||
completed.candidateCount = candidates.count
|
||||
completed.routedCount = routedCount
|
||||
completed.failedCount = candidates.count - routedCount
|
||||
completed.cachedCount = entries.count
|
||||
completed.complete = !unresolved
|
||||
completed.capped = capped
|
||||
completed.elapsedSeconds = Date().timeIntervalSince(passStart)
|
||||
await updateDebug(completed)
|
||||
#if DEBUG
|
||||
// `FB-ROUTE` prints what a pass actually cost (stations, route calls,
|
||||
// wall-clock, unroutable pins, whether the set is now complete) — the
|
||||
// evidence for any "how taxing is routing?" answer, and how a bigger pass
|
||||
// gets scaled from measurement instead of guesswork.
|
||||
print("FB-ROUTE pass stations=\(candidates.count) calls=\(candidates.count * 2) "
|
||||
+ "elapsed=\(String(format: "%.2f", Date().timeIntervalSince(passStart)))s "
|
||||
+ "cached=\(entries.count) failed=\(candidates.count - routedCount) "
|
||||
+ "complete=\(!unresolved)")
|
||||
#endif
|
||||
await notifyDistanceSurfaces()
|
||||
}
|
||||
|
||||
/// A cache can hold a route for EVERY station in the search area and still be
|
||||
/// flagged incomplete — a previous pass hit the capacity cap, or was
|
||||
/// interrupted between routing and writing. Nothing would ever clear that
|
||||
/// flag on its own (the next pass finds nothing left to route, and the
|
||||
/// throttle path returns without writing), and every row would stay on
|
||||
/// straight-line + "Approx." while its routed value sat in the cache. When
|
||||
/// nothing in the area is left unrouted, the set IS complete: correct the
|
||||
/// flag so the routed values can be served.
|
||||
private static func repairCompletenessIfNothingLeftToRoute(_ cache: RoadDistanceCache,
|
||||
stations: [FuelStation],
|
||||
lat: Double,
|
||||
lng: Double) async -> Bool {
|
||||
guard cache.complete != true, !stations.isEmpty else { return false }
|
||||
let nothingLeftToRoute = !stations.contains { station in
|
||||
station.distanceKM(to: lat, lng2: lng) <= maxRadiusKM
|
||||
&& !FuelStore.hasRoadDistance(for: station, userLat: lat, userLng: lng)
|
||||
}
|
||||
guard nothingLeftToRoute else { return false }
|
||||
FuelStore.saveRoadDistances(sourceLat: cache.sourceLat, sourceLng: cache.sourceLng,
|
||||
entries: cache.entries, complete: true)
|
||||
#if DEBUG
|
||||
print("FB-COMPLETE stale incomplete flag repaired cached=\(cache.entries.count) "
|
||||
+ "(every in-radius station already routed — no re-routing needed)")
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func notifyDistanceSurfacesOnMain() {
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
NotificationCenter.default.post(name: .fuelBoardRoadDistancesUpdated, object: nil)
|
||||
}
|
||||
|
||||
private static func notifyDistanceSurfaces() async {
|
||||
await MainActor.run { notifyDistanceSurfacesOnMain() }
|
||||
}
|
||||
|
||||
private static let refreshGate = RoadDistanceRefreshGate()
|
||||
|
||||
/// Accurate mode fills only while the app is on screen: the browse layer is
|
||||
/// the only consumer of routed distances, so a background fill would spend
|
||||
/// radio and battery routing a position nothing is looking at.
|
||||
private static func appIsActive() async -> Bool {
|
||||
await MainActor.run { UIApplication.shared.applicationState == .active }
|
||||
}
|
||||
|
||||
/// 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(
|
||||
location: CLLocation(latitude: from.latitude, longitude: from.longitude),
|
||||
address: nil
|
||||
)
|
||||
request.destination = MKMapItem(
|
||||
location: CLLocation(latitude: to.latitude, longitude: to.longitude),
|
||||
address: nil
|
||||
)
|
||||
request.transportType = .automobile
|
||||
request.requestsAlternateRoutes = false
|
||||
do {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
private actor RoadDistanceRefreshGate {
|
||||
private var isRefreshing = false
|
||||
|
||||
func begin() -> Bool {
|
||||
guard !isRefreshing else { return false }
|
||||
isRefreshing = true
|
||||
return true
|
||||
}
|
||||
|
||||
func end() {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,20 @@ 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 fuelSavingsSettings: FuelSavingsSettings
|
||||
/// The Best Value gate — the net saving that makes a station worth the trip.
|
||||
/// Edited here, with the fill size and MPG that PRODUCE that saving, rather
|
||||
/// than in the Alerts tab: the same figure also decides which station rows
|
||||
/// carry the BEST VALUE badge, so it is a fuel-savings setting first and an
|
||||
/// alert setting second. Stored under the alerts key either way.
|
||||
@Binding var bestValueThresholdGBP: Double
|
||||
@Binding var dataRefreshMode: DataRefreshMode
|
||||
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
||||
/// tab) so the test notification matches what real alerts will say.
|
||||
var alertsFuel: FuelType = .e10
|
||||
@@ -48,21 +60,19 @@ struct SettingsView: View {
|
||||
var regionEventCount: Int = 0
|
||||
/// Debug fence state (monitor.debugFenceIdentifier) — armed vs cleared.
|
||||
var debugFenceIdentifier: String?
|
||||
var roadDistanceDebugStatus: RoadDistanceFillDebugStatus = .idle
|
||||
/// Arms a 100 m fence at the current location (monitor.registerDebugFenceAroundMe).
|
||||
var onDebugFence: () -> Void = {}
|
||||
/// 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).
|
||||
@State private var debugMode: Bool = FuelStore.loadDebugMode()
|
||||
/// Dev-only: re-inserts the LAN relay into the live chain (off by default —
|
||||
/// consumers must never attempt local-network access).
|
||||
@State private var relayFallbackEnabled: Bool = FuelStore.loadRelayFallbackEnabled()
|
||||
/// "Accurate distances" — read once on appear and written straight through to
|
||||
/// the shared store, which is what every display surface actually consults.
|
||||
@State private var accurateDistances: Bool = FuelStore.accurateDistancesEnabled
|
||||
@State private var versionTapCount = 0
|
||||
@State private var lastVersionTap = Date.distantPast
|
||||
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
|
||||
@@ -71,6 +81,18 @@ struct SettingsView: View {
|
||||
@State private var mediumWidgetDiag: String?
|
||||
@State private var installedWidgets: String = ""
|
||||
|
||||
/// Which numeric field on this screen is being edited. `.decimalPad` has no
|
||||
/// Return key, so the keyboard's Done button clears this to dismiss AND
|
||||
/// commit the typed value. Both Fuel Savings controls are sliders now, so
|
||||
/// nothing sets it — it stays for any numeric field this screen takes again.
|
||||
@State private var settingsUnitChangeNotice: String?
|
||||
@FocusState private var focusedSavingsField: SavingsField?
|
||||
|
||||
private enum SavingsField: Hashable {
|
||||
case averageMPG
|
||||
case fillAmount
|
||||
}
|
||||
|
||||
private func refreshWidgetDiag() {
|
||||
smallWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardSmallWidget")
|
||||
mediumWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardWidgetConfigurationIntent")
|
||||
@@ -93,6 +115,78 @@ struct SettingsView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Toggle("Best Value", isOn: fuelSavingsEnabledBinding)
|
||||
// The feature's own controls follow its toggle: switched
|
||||
// off they grey out but stay on screen (same rule as the
|
||||
// distance picker in Closest mode) and keep their values, so
|
||||
// re-enabling restores exactly what was there. The sections
|
||||
// that USE the feature — the in-list fill-up control and the
|
||||
// Best Value sort option — are gated on `isConfigured`,
|
||||
// which already includes `enabled`, so they hide entirely.
|
||||
Group {
|
||||
HStack {
|
||||
Text("Average fuel economy")
|
||||
Spacer()
|
||||
Text(mpgText)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Slider(value: fuelSavingsMPGBinding,
|
||||
in: FuelSavingsCalculator.mpgSliderRange,
|
||||
step: FuelSavingsCalculator.mpgSliderStep)
|
||||
.accessibilityLabel("Average fuel economy")
|
||||
.accessibilityValue(mpgText)
|
||||
Picker("Fill-up unit", selection: fuelSavingsUnitBinding) {
|
||||
ForEach(FuelSavingsFillUnit.allCases) { unit in
|
||||
Text(unit.displayName).tag(unit)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
HStack {
|
||||
Text("Typical fill-up")
|
||||
Spacer()
|
||||
Text(fillUpText)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Slider(value: fuelSavingsAmountBinding,
|
||||
in: FuelSavingsCalculator.fillAmountSliderRange(for: fuelSavingsSettings.preferredAmount.unit),
|
||||
step: FuelSavingsCalculator.fillAmountSliderStep(for: fuelSavingsSettings.preferredAmount.unit))
|
||||
.accessibilityLabel("Typical fill-up")
|
||||
.accessibilityValue(fillUpText)
|
||||
Picker("Minimum saving", selection: $bestValueThresholdGBP) {
|
||||
ForEach(FuelStore.bestValueAlertThresholdOptionsGBP, id: \.self) { value in
|
||||
Text(FuelSavingsFormat.money(value)).tag(value)
|
||||
}
|
||||
}
|
||||
// Persisted by ContentView's single
|
||||
// `.onChange(of: alertsBestValueThresholdGBP)`, which
|
||||
// also refreshes the alert monitor — so this picker
|
||||
// needs no handler of its own. The store snaps to the
|
||||
// nearest option, so the picker and the stored value
|
||||
// cannot disagree.
|
||||
Text("A saving worth driving for: the net saving left after the extra travel cost. Stations above it are labelled BEST VALUE in the station list, and Best Value alerts use the same figure.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let settingsUnitChangeNotice {
|
||||
Text(settingsUnitChangeNotice)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if !fuelSavingsSettings.isConfigured && fuelSavingsSettings.enabled {
|
||||
Text("Set a realistic UK MPG and fill-up amount before Best Value appears in sorting.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.disabled(!fuelSavingsSettings.enabled)
|
||||
} header: {
|
||||
Text("Fuel Savings")
|
||||
} footer: {
|
||||
Text("Best Value estimates fuel cost only, using UK imperial MPG, your typical purchase amount, fuel prices and Apple Maps road distances. Real-world average MPG is better than manufacturer figures; the fill-up amount is your usual purchase, not tank capacity.")
|
||||
}
|
||||
|
||||
Section {
|
||||
Picker("Distance", selection: $distanceUnit) {
|
||||
ForEach(DistanceUnit.allCases) { unit in
|
||||
@@ -103,6 +197,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 +208,7 @@ struct SettingsView: View {
|
||||
.onChange(of: priceDisplayStyle) { _, newValue in
|
||||
FuelStore.savePriceDisplayStyle(newValue)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
WatchSyncManager.shared.pushSnapshot()
|
||||
}
|
||||
} header: {
|
||||
Text("Units")
|
||||
@@ -120,6 +216,60 @@ 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 {
|
||||
Toggle("Accurate distances", isOn: $accurateDistances)
|
||||
.onChange(of: accurateDistances) { _, newValue in
|
||||
FuelStore.setAccurateDistancesEnabled(newValue)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
NotificationCenter.default.post(name: .fuelBoardAccurateDistancesChanged,
|
||||
object: nil)
|
||||
}
|
||||
} header: {
|
||||
Text("Distances")
|
||||
} footer: {
|
||||
Text("Off (default): distances are straight-line estimates marked Approx. and the app makes no route requests. On: Apple Maps driving distances for every station in range, filled only while this app is open. Search radii then mean miles of driving.")
|
||||
}
|
||||
|
||||
Section {
|
||||
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()
|
||||
@@ -132,10 +282,6 @@ struct SettingsView: View {
|
||||
|
||||
if debugMode {
|
||||
Section {
|
||||
Toggle("LAN relay fallback (dev)", isOn: $relayFallbackEnabled)
|
||||
.onChange(of: relayFallbackEnabled) { _, enabled in
|
||||
FuelStore.saveRelayFallbackEnabled(enabled)
|
||||
}
|
||||
Button {
|
||||
sendTestAlert()
|
||||
} label: {
|
||||
@@ -165,7 +311,7 @@ struct SettingsView: View {
|
||||
} header: {
|
||||
Text("Debug")
|
||||
} footer: {
|
||||
Text("The first button applies the real criteria — cheapest \(alertsFuel.displayName.lowercased()) station within \(distanceUnit.format(alertsRadiusKM)) of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (nearest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius.")
|
||||
Text("The first button applies the real criteria — cheapest \(alertsFuel.displayName.lowercased()) station within \(distanceUnit.format(alertsRadiusKM)) of you — and sends the actual station's name, price and coordinates. The second fires with no criteria at all (closest seller), still carrying a real station, just to verify a notification appears and tapping it opens directions. Cheapest in range mirrors the app list (selected fuel + distance); the in-range count mirrors the alert radius.")
|
||||
}
|
||||
.onAppear { onRefreshDebugStatus() }
|
||||
|
||||
@@ -205,6 +351,55 @@ struct SettingsView: View {
|
||||
}
|
||||
.onAppear { refreshWidgetDiag() }
|
||||
|
||||
Section {
|
||||
Button {
|
||||
Task { await tipStore.load() }
|
||||
} label: {
|
||||
Label("Probe App Store products", systemImage: "arrow.clockwise")
|
||||
}
|
||||
LabeledContent("Probe", value: tipStore.productLoadStatus)
|
||||
if let loadedAt = tipStore.lastProductLoadDate {
|
||||
LabeledContent("Last probe", value: loadedAt.formatted(date: .abbreviated, time: .standard))
|
||||
}
|
||||
LabeledContent("Requested", value: "\(TipStore.tiers.count) IDs")
|
||||
LabeledContent("Returned", value: "\(tipStore.debugProducts.count) products")
|
||||
if !tipStore.debugProducts.isEmpty {
|
||||
ForEach(tipStore.debugProducts) { product in
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(product.id)
|
||||
.font(.caption.monospaced())
|
||||
.textSelection(.enabled)
|
||||
Text(product.displayName)
|
||||
.font(.footnote.weight(.medium))
|
||||
Text("\(product.type) · \(product.displayPrice) · raw \(product.price)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("No products were returned by StoreKit.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if !tipStore.missingProductIDs.isEmpty {
|
||||
Text("Missing IDs: \(tipStore.missingProductIDs.joined(separator: ", "))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.orange)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if let error = tipStore.lastProductLoadError {
|
||||
Text("Error: \(error)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
} header: {
|
||||
Text("IAP Diagnostics")
|
||||
} footer: {
|
||||
Text("Shows the exact product identifiers requested and the StoreKit products returned. Product names and prices are read from StoreKit; missing IDs were not returned by the current App Store environment.")
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Text("Connection")
|
||||
@@ -232,6 +427,36 @@ struct SettingsView: View {
|
||||
Text("Connection shows the source of the current prices (CSV mirror or API). Data updated is the latest price change reported by the GOV.UK server itself. Prices use the UK Fuel Finder dataset (Open Government Licence) and refresh up to twice a day.")
|
||||
}
|
||||
|
||||
Section {
|
||||
LabeledContent("Status", value: roadDistanceDebugStatus.summary)
|
||||
LabeledContent("Updated", value: roadDistanceDebugStatus.updatedAt.formatted(date: .omitted, time: .standard))
|
||||
if let lat = roadDistanceDebugStatus.originLat,
|
||||
let lng = roadDistanceDebugStatus.originLng {
|
||||
LabeledContent("Origin", value: String(format: "%.5f, %.5f", lat, lng))
|
||||
}
|
||||
LabeledContent("Pool", value: "\(roadDistanceDebugStatus.stationPoolCount)")
|
||||
LabeledContent("Candidates", value: "\(roadDistanceDebugStatus.candidateCount)")
|
||||
LabeledContent("Cached", value: "\(roadDistanceDebugStatus.cachedCount)")
|
||||
if roadDistanceDebugStatus.routedCount > 0 || roadDistanceDebugStatus.failedCount > 0 {
|
||||
LabeledContent("Routed", value: "\(roadDistanceDebugStatus.routedCount)")
|
||||
LabeledContent("Failed", value: "\(roadDistanceDebugStatus.failedCount)")
|
||||
}
|
||||
if let elapsed = roadDistanceDebugStatus.elapsedSeconds {
|
||||
LabeledContent("Elapsed", value: String(format: "%.1fs", elapsed))
|
||||
}
|
||||
if let moved = roadDistanceDebugStatus.movedMeters {
|
||||
LabeledContent("Moved", value: String(format: "%.0f m", moved))
|
||||
}
|
||||
if let remaining = roadDistanceDebugStatus.throttleSecondsRemaining {
|
||||
LabeledContent("Throttle", value: String(format: "%.0f s remaining", max(0, remaining)))
|
||||
}
|
||||
LabeledContent("Ready", value: roadDistanceDebugStatus.complete ? "yes" : "no")
|
||||
} header: {
|
||||
Text("Road distance fill")
|
||||
} footer: {
|
||||
Text("Shows whether Accurate distances is running or gated. Distances stay Approx. until Ready is yes for the current origin; gated reasons explain why no fill is happening.")
|
||||
}
|
||||
|
||||
Section {
|
||||
if monitoredCount == 0 {
|
||||
Text("No stations monitored yet — open the Stations tab to load prices first.")
|
||||
@@ -359,14 +584,10 @@ struct SettingsView: View {
|
||||
Text("About")
|
||||
}
|
||||
|
||||
if let message = tipStore.message {
|
||||
Section {
|
||||
Text(message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Numeric entry must be escapable: `.decimalPad` has no Return key.
|
||||
.keyboardDoneButton { focusedSavingsField = nil }
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.navigationTitle("Settings")
|
||||
.onAppear {
|
||||
Task { await tipStore.load() }
|
||||
@@ -486,6 +707,62 @@ struct SettingsView: View {
|
||||
return "\(version) (\(build))"
|
||||
}
|
||||
|
||||
private var fuelSavingsEnabledBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { fuelSavingsSettings.enabled },
|
||||
set: { fuelSavingsSettings.enabled = $0; persistFuelSavingsSettings() }
|
||||
)
|
||||
}
|
||||
|
||||
private var fuelSavingsMPGBinding: Binding<Double> {
|
||||
Binding(
|
||||
get: { fuelSavingsSettings.mpg },
|
||||
set: { fuelSavingsSettings.mpg = min(max($0, 0), FuelSavingsCalculator.maximumMPG); persistFuelSavingsSettings() }
|
||||
)
|
||||
}
|
||||
|
||||
private var fuelSavingsAmountBinding: Binding<Double> {
|
||||
Binding(
|
||||
get: { fuelSavingsSettings.preferredAmount.value },
|
||||
set: {
|
||||
fuelSavingsSettings.preferredAmount.value = min(max($0, 0), FuelSavingsCalculator.maximumFillAmount(for: fuelSavingsSettings.preferredAmount.unit))
|
||||
persistFuelSavingsSettings()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var fuelSavingsUnitBinding: Binding<FuelSavingsFillUnit> {
|
||||
Binding(
|
||||
get: { fuelSavingsSettings.preferredAmount.unit },
|
||||
set: { newUnit in
|
||||
guard newUnit != fuelSavingsSettings.preferredAmount.unit else { return }
|
||||
fuelSavingsSettings.preferredAmount = FuelSavingsCalculator.newComparisonAmount(
|
||||
afterSwitching: fuelSavingsSettings.preferredAmount, to: newUnit)
|
||||
settingsUnitChangeNotice = "New comparison basis — adjust the \(newUnit.displayName) amount to your intended fill-up."
|
||||
persistFuelSavingsSettings()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// "45 MPG" / "45.5 MPG". The slider is the only control, so this read-out
|
||||
/// is the one place the figure is stated.
|
||||
private var mpgText: String {
|
||||
let mpg = fuelSavingsSettings.mpg
|
||||
return mpg.rounded() == mpg ? String(format: "%.0f MPG", mpg) : String(format: "%.1f MPG", mpg)
|
||||
}
|
||||
|
||||
/// "£40" / "40 L" — the fill-up slider's read-out.
|
||||
private var fillUpText: String {
|
||||
switch fuelSavingsSettings.preferredAmount.unit {
|
||||
case .pounds: return String(format: "£%.0f", fuelSavingsSettings.preferredAmount.value)
|
||||
case .litres: return String(format: "%.0f L", fuelSavingsSettings.preferredAmount.value)
|
||||
}
|
||||
}
|
||||
|
||||
private func persistFuelSavingsSettings() {
|
||||
FuelStore.saveFuelSavingsSettings(fuelSavingsSettings)
|
||||
}
|
||||
|
||||
/// "API" when the relay is serving the official Fuel Finder API, "CSV"
|
||||
/// when it fell back to the public mirror, "—" when unknown/never fetched.
|
||||
private var connectionText: String {
|
||||
@@ -594,25 +871,50 @@ final class TipStore: ObservableObject {
|
||||
let id: String // product ID
|
||||
let name: String
|
||||
let blurb: String
|
||||
let fallbackPrice: String
|
||||
let accent: Color // card/price accent (matches the fuel palette)
|
||||
}
|
||||
|
||||
static let tiers: [TipTier] = [
|
||||
TipTier(id: "com.apt.fuelboard.tip099", name: "Splash & Dash",
|
||||
blurb: "Just enough to keep things moving.", fallbackPrice: "£0.99",
|
||||
blurb: "Just enough to keep things moving.",
|
||||
accent: Color(red: 0.39, green: 0.82, blue: 1.0)), // #64D2FF
|
||||
TipTier(id: "com.apt.fuelboard.tip299", name: "Half a Tank",
|
||||
blurb: "A generous top-up for development.", fallbackPrice: "£2.99",
|
||||
blurb: "A generous top-up for development.",
|
||||
accent: Color(red: 0.19, green: 0.82, blue: 0.35)), // #30D158
|
||||
TipTier(id: "com.apt.fuelboard.tip499", name: "Fill 'Er Up",
|
||||
blurb: "Keeping the app on the road.", fallbackPrice: "£4.99",
|
||||
blurb: "Keeping the app on the road.",
|
||||
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?
|
||||
@Published private(set) var productLoadStatus = "Not probed"
|
||||
@Published private(set) var lastProductLoadDate: Date?
|
||||
@Published private(set) var lastProductLoadError: String?
|
||||
@Published private(set) var debugProducts: [DebugProduct] = []
|
||||
|
||||
struct DebugProduct: Identifiable {
|
||||
let id: String
|
||||
let displayName: String
|
||||
let type: String
|
||||
let displayPrice: String
|
||||
let price: String
|
||||
}
|
||||
|
||||
var missingProductIDs: [String] {
|
||||
Self.tiers.map(\.id).filter { products[$0] == nil }
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -620,9 +922,31 @@ final class TipStore: ObservableObject {
|
||||
/// the queue but are never finished or acknowledged.
|
||||
private var updatesTask: Task<Void, Never>?
|
||||
|
||||
/// Drains verified FuelBoard transactions that were already unfinished
|
||||
/// before this store was created (for example after termination during a
|
||||
/// sandbox purchase). `Transaction.updates` only covers updates delivered
|
||||
/// while the listener is active, so both sequences are required.
|
||||
private var unfinishedTask: Task<Void, Never>?
|
||||
|
||||
/// Uses the existing hidden developer switch in Settings. StoreKit
|
||||
/// diagnostics must stay silent unless the developer explicitly enables
|
||||
/// Debug mode with the five-tap Version gesture.
|
||||
private static func debugLog(_ message: @autoclosure () -> String) {
|
||||
guard FuelStore.loadDebugMode() else { return }
|
||||
print(message())
|
||||
}
|
||||
|
||||
init() {
|
||||
Self.debugLog("[FuelBoard StoreKit] TipStore initialized; expected IDs=\(Self.tiers.map(\.id).joined(separator: ","))")
|
||||
updatesTask = Task { [weak self] in
|
||||
for await update in Transaction.updates {
|
||||
Self.debugLog("[FuelBoard StoreKit] Transaction.updates received")
|
||||
await self?.handle(update)
|
||||
}
|
||||
}
|
||||
unfinishedTask = Task { [weak self] in
|
||||
for await update in Transaction.unfinished {
|
||||
Self.debugLog("[FuelBoard StoreKit] Transaction.unfinished received")
|
||||
await self?.handle(update)
|
||||
}
|
||||
}
|
||||
@@ -630,69 +954,187 @@ final class TipStore: ObservableObject {
|
||||
|
||||
deinit {
|
||||
updatesTask?.cancel()
|
||||
unfinishedTask?.cancel()
|
||||
dismissTask?.cancel()
|
||||
}
|
||||
|
||||
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
|
||||
Self.debugLog("[FuelBoard StoreKit] Handling transaction update")
|
||||
// Never deliver or finish an untrusted purchase.
|
||||
guard case .verified(let transaction) = update else { return }
|
||||
guard case .verified(let transaction) = update else {
|
||||
if case .unverified(_, let error) = update {
|
||||
Self.debugLog("[FuelBoard StoreKit] Ignoring unverified transaction error=\(error.localizedDescription)")
|
||||
} else {
|
||||
Self.debugLog("[FuelBoard StoreKit] Ignoring unverified transaction update")
|
||||
}
|
||||
return
|
||||
}
|
||||
// Only acknowledge our own consumables (future products get their own).
|
||||
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
|
||||
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else {
|
||||
Self.debugLog("[FuelBoard StoreKit] Ignoring unrelated product ID=\(transaction.productID)")
|
||||
return
|
||||
}
|
||||
Self.debugLog("[FuelBoard StoreKit] Finishing verified transaction productID=\(transaction.productID)")
|
||||
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 {
|
||||
products[tier.id]?.displayPrice ?? tier.fallbackPrice
|
||||
products[tier.id]?.displayPrice
|
||||
?? NSLocalizedString("Unavailable", comment: "Tip product has not been returned by StoreKit")
|
||||
}
|
||||
|
||||
func load() async {
|
||||
// Refreshes product state on every visit so newly-approved products
|
||||
// (or restored transactions) are picked up.
|
||||
let ids = Self.tiers.map(\.id)
|
||||
productLoadStatus = "Loading…"
|
||||
lastProductLoadError = nil
|
||||
Self.debugLog("[FuelBoard StoreKit] Loading products IDs=\(ids.joined(separator: ","))")
|
||||
do {
|
||||
let fetched = try await Product.products(for: Self.tiers.map(\.id))
|
||||
let fetched = try await Product.products(for: ids)
|
||||
lastProductLoadDate = Date()
|
||||
Self.debugLog("[FuelBoard StoreKit] Product.products returned count=\(fetched.count) IDs=\(fetched.map(\.id).joined(separator: ","))")
|
||||
for product in fetched {
|
||||
Self.debugLog("[FuelBoard StoreKit] Product id=\(product.id) type=\(String(describing: product.type)) displayPrice=\(product.displayPrice) price=\(product.price)")
|
||||
}
|
||||
products = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
|
||||
debugProducts = fetched.map {
|
||||
DebugProduct(
|
||||
id: $0.id,
|
||||
displayName: $0.displayName,
|
||||
type: String(describing: $0.type),
|
||||
displayPrice: $0.displayPrice,
|
||||
price: String(describing: $0.price)
|
||||
)
|
||||
}
|
||||
productLoadStatus = fetched.isEmpty ? "Completed — zero returned" : "Completed"
|
||||
let matched = Self.tiers.filter { products[$0.id] != nil }.map(\.id)
|
||||
Self.debugLog("[FuelBoard StoreKit] Matched products after load=\(matched.joined(separator: ","))")
|
||||
} catch {
|
||||
// No products yet (sideloaded build) — the buttons still show the
|
||||
// intended prices and report purchase attempts gracefully.
|
||||
lastProductLoadDate = Date()
|
||||
productLoadStatus = "Failed"
|
||||
lastProductLoadError = "\(String(describing: type(of: error))): \(error.localizedDescription)"
|
||||
debugProducts = []
|
||||
Self.debugLog("[FuelBoard StoreKit] Product load failed type=\(String(describing: type(of: error))) error=\(error.localizedDescription)")
|
||||
// Keep the cards visible so the user can retry while App Store
|
||||
// Connect metadata propagates; displayPrice() shows "Unavailable"
|
||||
// until StoreKit returns a real product.
|
||||
products = [:]
|
||||
}
|
||||
}
|
||||
|
||||
private func merge(_ fetched: [Product]) {
|
||||
products.merge(Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })) { _, new in new }
|
||||
}
|
||||
|
||||
private func product(for tier: TipTier) async throws -> Product? {
|
||||
if let product = products[tier.id] {
|
||||
Self.debugLog("[FuelBoard StoreKit] Using cached product ID=\(tier.id)")
|
||||
return product
|
||||
}
|
||||
|
||||
// TestFlight/App Store Connect product availability can lag the Settings
|
||||
// onAppear load. Re-query the tapped tier before falling back so a newly
|
||||
// approved IAP opens the system purchase sheet instead of showing stale
|
||||
// "not available" copy.
|
||||
Self.debugLog("[FuelBoard StoreKit] Re-querying tapped product ID=\(tier.id)")
|
||||
let fetched = try await Product.products(for: [tier.id])
|
||||
Self.debugLog("[FuelBoard StoreKit] Re-query returned count=\(fetched.count) IDs=\(fetched.map(\.id).joined(separator: ","))")
|
||||
merge(fetched)
|
||||
let match = fetched.first { $0.id == tier.id }
|
||||
Self.debugLog("[FuelBoard StoreKit] Tapped product match=\(match != nil) ID=\(tier.id)")
|
||||
return match
|
||||
}
|
||||
|
||||
func purchase(_ tier: TipTier) async {
|
||||
guard !purchaseInProgress else { return }
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase requested ID=\(tier.id) name=\(tier.name)")
|
||||
guard !purchaseInProgress else {
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase ignored because another purchase is in progress")
|
||||
return
|
||||
}
|
||||
purchaseInProgress = true
|
||||
defer { purchaseInProgress = false }
|
||||
|
||||
// If the product hasn't loaded (e.g. not configured in App Store
|
||||
// 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: "")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
guard let product = try await product(for: tier) else {
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase stopped: no StoreKit product for ID=\(tier.id)")
|
||||
showOutcome(
|
||||
NSLocalizedString("This tip isn't available from the App Store yet. Please try again shortly.", comment: ""),
|
||||
icon: "info.circle.fill",
|
||||
tint: Color(.secondaryLabel)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Self.debugLog("[FuelBoard StoreKit] Calling product.purchase() ID=\(product.id) displayPrice=\(product.displayPrice)")
|
||||
let result = try await product.purchase()
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase result=success")
|
||||
switch verification {
|
||||
case .verified(let transaction):
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase verified productID=\(transaction.productID); finishing")
|
||||
// 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: "")
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase result=unverified")
|
||||
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
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase result=userCancelled")
|
||||
dismissOutcome() // silent — the user just closed the sheet
|
||||
case .pending:
|
||||
message = NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: "")
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase result=pending")
|
||||
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
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase result=unknown")
|
||||
dismissOutcome()
|
||||
}
|
||||
} catch {
|
||||
message = String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription)
|
||||
Self.debugLog("[FuelBoard StoreKit] Purchase threw type=\(String(describing: type(of: error))) error=\(error.localizedDescription)")
|
||||
showOutcome(
|
||||
String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription),
|
||||
icon: "xmark.circle.fill",
|
||||
tint: .red
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import SwiftUI
|
||||
import MapKit
|
||||
import UIKit
|
||||
|
||||
/// DEBUG-only explanation of the Best Value numbers for one station. It carries
|
||||
/// no precise user coordinates and is never persisted.
|
||||
struct FuelSavingsJourneyDiagnostics {
|
||||
let rows: [(label: String, value: String)]
|
||||
}
|
||||
|
||||
/// Detail sheet for a tapped station (when Settings → Tap station = More info).
|
||||
/// Shows a small map preview, the address, distance from the current location,
|
||||
/// and a Directions button that opens Apple Maps — the same destination a direct
|
||||
/// tap would have opened. The station name lives in the sheet header, not in a
|
||||
/// row.
|
||||
struct StationDetailView: View {
|
||||
let station: FuelStation
|
||||
let location: Coordinate?
|
||||
let distanceUnit: DistanceUnit
|
||||
/// Fuel whose price is being shown by the surrounding station list.
|
||||
var selectedFuel: FuelType = FuelStore.loadSelectedFuel()
|
||||
var fuelSavings: FuelSavingsAvailability? = nil
|
||||
var fuelSavingsAmount: FuelSavingsFillAmount = FuelSavingsSettings.defaultPreferredAmount
|
||||
var savingsBaselineStationName: String? = nil
|
||||
var journeyDiagnostics: FuelSavingsJourneyDiagnostics? = nil
|
||||
/// Whether this station is starred for the fuel currently being viewed, and
|
||||
/// the toggle that flips it. Passed from the list that opened the sheet, so
|
||||
/// the star here and the star on the row are the same fuel-scoped
|
||||
/// favourite and cannot drift apart.
|
||||
var isFavourite: Bool = false
|
||||
var onToggleFavourite: (() -> Void)? = nil
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showingReportIssueConfirmation = false
|
||||
/// Transient "Postcode copied" confirmation on the Address row. Cleared by a task so
|
||||
/// the caption fades out on its own.
|
||||
@State private var showingCopied = false
|
||||
@State private var copyResetTask: Task<Void, Never>?
|
||||
/// The "How Best Value works" sheet, opened from the row at the bottom of the
|
||||
/// Best Value card (the Key sheet opens the same shared view).
|
||||
@State private var showBestValueInfo = false
|
||||
|
||||
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
|
||||
|
||||
private var stationCoordinate: CLLocationCoordinate2D {
|
||||
CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
||||
}
|
||||
|
||||
/// The row's address text — the same shared line the listing row uses, with
|
||||
/// any leading repeat of the station name stripped.
|
||||
private var addressLine: String {
|
||||
StationAddressLine.display(address: station.address,
|
||||
postcode: station.postcode,
|
||||
name: station.name)
|
||||
}
|
||||
|
||||
/// The station's postcode, trimmed. Empty means there is nothing to copy and
|
||||
/// the Address row stays inert.
|
||||
private var postcodeForCopying: String {
|
||||
station.postcode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private var priceFreshness: FuelPriceFreshness.Assessment {
|
||||
FuelPriceFreshness.assessment(for: station)
|
||||
}
|
||||
|
||||
/// The selected grade's current price, when present. Kept separate from
|
||||
/// the price-age row so an old price remains visible rather than being
|
||||
/// silently hidden.
|
||||
private var selectedFuelPriceText: String {
|
||||
guard let price = station.prices[selectedFuel] else { return "—" }
|
||||
return FuelStore.priceText(price, style: .stationSign)
|
||||
}
|
||||
|
||||
/// Opens Apple Maps driving directions — the same destination (and the same
|
||||
/// URL) the Directions button below uses, so the map and the button can
|
||||
/// never point at two different places.
|
||||
private func openDirections() {
|
||||
if let url = station.mapsDirectionsURL {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies ONLY the postcode, so it can be pasted into another navigation app
|
||||
/// without dragging the station name and street along with it. Confirms with
|
||||
/// a light haptic and a caption that fades after ~2s.
|
||||
private func copyPostcode() {
|
||||
let postcode = postcodeForCopying
|
||||
guard !postcode.isEmpty else { return }
|
||||
UIPasteboard.general.string = postcode
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
withAnimation(.easeInOut(duration: 0.2)) { showingCopied = true }
|
||||
copyResetTask?.cancel()
|
||||
copyResetTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
withAnimation(.easeInOut(duration: 0.3)) { showingCopied = false }
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
// Tapping anywhere on the map opens the same Apple Maps
|
||||
// directions the Directions button below opens.
|
||||
//
|
||||
// The tap target is an OVERLAY Button laid ON TOP of the
|
||||
// Map, not a Button wrapping it. A Map runs its own UIKit
|
||||
// view, and inside a List row the wrapped-Map form left the
|
||||
// card untappable — the touch never reached the button. An
|
||||
// overlay sits above the map, owns an explicit rounded-rect
|
||||
// content shape, and cannot be intercepted by MapKit. The
|
||||
// Map itself stays non-interactive: it is a preview, not
|
||||
// something to pan.
|
||||
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)
|
||||
.overlay {
|
||||
Button {
|
||||
openDirections()
|
||||
} label: {
|
||||
Color.clear
|
||||
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(String(localized: "Directions to \(station.name)",
|
||||
comment: "Accessibility label for the tappable map preview in the More info sheet"))
|
||||
.accessibilityHint(String(localized: "Opens Apple Maps with driving directions",
|
||||
comment: "Accessibility hint for the tappable map preview in the More info sheet"))
|
||||
}
|
||||
// No hairline between the map and the Address row: the map
|
||||
// already reads as its own block, so the line only added
|
||||
// clutter. The Address/Distance and Distance/actions lines
|
||||
// stay.
|
||||
.listRowSeparator(.hidden, edges: .bottom)
|
||||
// The map, the address, the distance, any notices and the two
|
||||
// station actions all share ONE card (the list's own row
|
||||
// hairlines separate them), so no hand-rolled Divider here —
|
||||
// adding one would double the line.
|
||||
//
|
||||
// The station name is NOT repeated: the sheet header already
|
||||
// shows it.
|
||||
//
|
||||
// The whole row is a copy control: it puts JUST the postcode
|
||||
// on the clipboard so it can be pasted into another
|
||||
// navigation app. The copy glyph sits ON the address line,
|
||||
// and the "Postcode copied" confirmation is a capsule that floats OVER
|
||||
// THIS ROW — it confirms the address, so it must not hover
|
||||
// over the map. It is an overlay, so nothing moves while it is
|
||||
// up, and `allowsHitTesting(false)` keeps the row tappable
|
||||
// through the capsule.
|
||||
Button {
|
||||
copyPostcode()
|
||||
} label: {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text("Address")
|
||||
Spacer(minLength: 8)
|
||||
Text(addressLine)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.foregroundStyle(.secondary)
|
||||
// Stays the plain copy glyph — the capsule carries
|
||||
// the confirmation, so swapping this to a checkmark
|
||||
// would say it twice.
|
||||
Image(systemName: "doc.on.doc")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(postcodeForCopying.isEmpty)
|
||||
.accessibilityLabel(String(localized: "Address: \(addressLine)",
|
||||
comment: "Accessibility label for the address row in the More info sheet"))
|
||||
.accessibilityHint(postcodeForCopying.isEmpty
|
||||
? ""
|
||||
: String(localized: "Copies the postcode",
|
||||
comment: "Accessibility hint for the address row in the More info sheet"))
|
||||
// Horizontally centred over the address line itself.
|
||||
.overlay(alignment: .center) {
|
||||
if showingCopied {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark")
|
||||
Text("Postcode copied")
|
||||
}
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.green)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
// A material base keeps it legible where the tint
|
||||
// alone (the notice-capsule recipe) washes out.
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(.regularMaterial)
|
||||
.overlay(Capsule().fill(Color.green.opacity(0.22)))
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
.accessibilityHidden(true)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
if let location {
|
||||
let distance = FuelStore.stationDistanceDisplay(
|
||||
station: station, userLat: location.lat, userLng: location.lng
|
||||
)
|
||||
LabeledContent("Distance", value: distance.labelWithReturn(unit: distanceUnit))
|
||||
.monospacedDigit()
|
||||
}
|
||||
LabeledContent("\(selectedFuel.displayName) price", value: selectedFuelPriceText)
|
||||
.monospacedDigit()
|
||||
StationPriceAgeView(assessment: priceFreshness)
|
||||
ForEach(StationNotice.notices(for: station)) { notice in
|
||||
Label(notice.title, systemImage: notice.symbolName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.orange)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Capsule().fill(Color.orange.opacity(0.14)))
|
||||
}
|
||||
// The two station actions are the LAST row of the card: half
|
||||
// the width each, on one line, below Address and Distance.
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
openDirections()
|
||||
} label: {
|
||||
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
showingReportIssueConfirmation = true
|
||||
} label: {
|
||||
Label("Report issue", systemImage: "exclamationmark.bubble.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
// REQUIRED: with the default button style a `List` row has ONE
|
||||
// row-wide tap target, so two buttons in the same row BOTH fire
|
||||
// on any tap inside the row (tapping Directions also showed the
|
||||
// report-issue alert and vice versa). `.borderless` gives each
|
||||
// button its own hit area — the half-row frame above.
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
// Only shown while Fuel Savings backs it. `fuelSavings` is nil
|
||||
// when the feature is off — `savingsLookup` returns nil when it
|
||||
// is not configured — so the heading cannot be left dangling
|
||||
// over an empty section.
|
||||
if let fuelSavings {
|
||||
Section {
|
||||
FuelSavingsSummaryView(
|
||||
availability: fuelSavings,
|
||||
amount: fuelSavingsAmount,
|
||||
baselineStationName: savingsBaselineStationName,
|
||||
distanceUnit: distanceUnit,
|
||||
showInfoAction: { showBestValueInfo = true }
|
||||
)
|
||||
} header: {
|
||||
Text("Best Value")
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
if let journeyDiagnostics {
|
||||
Section("Best Value Diagnostics") {
|
||||
ForEach(journeyDiagnostics.rows, id: \.label) { row in
|
||||
LabeledContent(row.label, value: row.value)
|
||||
.font(.caption)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
.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.")
|
||||
}
|
||||
// Nested sheet: the More info sheet stays put behind the explanation.
|
||||
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button {
|
||||
onToggleFavourite?()
|
||||
} label: {
|
||||
Image(systemName: isFavourite ? "star.fill" : "star")
|
||||
}
|
||||
.tint(isFavourite ? Color.yellow : nil)
|
||||
.disabled(onToggleFavourite == nil)
|
||||
.accessibilityLabel(
|
||||
isFavourite
|
||||
? String(localized: "Remove from favourites", comment: "Unstar a station from the More info sheet")
|
||||
: String(localized: "Add to favourites", comment: "Star a station from the More info sheet")
|
||||
)
|
||||
}
|
||||
}
|
||||
// The "Postcode copied" fade-out task must not outlive the sheet.
|
||||
.onDisappear { copyResetTask?.cancel() }
|
||||
#if DEBUG
|
||||
// QA hook: `-qaCopyPostcode` runs the REAL copy on open, so the
|
||||
// clipboard value (read back with `xcrun simctl pbpaste`) and the
|
||||
// "Postcode copied" confirmation can be verified without a tap — simctl
|
||||
// cannot drive taps into the Simulator.
|
||||
.task {
|
||||
guard ProcessInfo.processInfo.arguments.contains("-qaCopyPostcode") else { return }
|
||||
copyPostcode()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
/// Presentation-only notices derived from existing station metadata.
|
||||
///
|
||||
/// These do not alter `FuelStation`, persistence, filtering, sorting, or
|
||||
/// navigation. They are intentionally small and reusable so additional
|
||||
/// informational station notices can be added later from the UI layer.
|
||||
enum StationNotice: Identifiable, Equatable {
|
||||
case membershipRequired
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .membershipRequired: return "membershipRequired"
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .membershipRequired: return "Membership required"
|
||||
}
|
||||
}
|
||||
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .membershipRequired: return "creditcard.fill"
|
||||
}
|
||||
}
|
||||
|
||||
static func notices(for station: FuelStation) -> [StationNotice] {
|
||||
isCostco(station) ? [.membershipRequired] : []
|
||||
}
|
||||
|
||||
static func isCostco(_ station: FuelStation) -> Bool {
|
||||
containsCostcoToken(station.brand) || containsCostcoToken(station.name)
|
||||
}
|
||||
|
||||
private static func containsCostcoToken(_ value: String) -> Bool {
|
||||
let tokens = value.uppercased().split { !$0.isLetter && !$0.isNumber }
|
||||
return tokens.contains("COSTCO")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The More info sheet's price-age row.
|
||||
///
|
||||
/// Station-level by design: the relay publishes a single `price_updated` per
|
||||
/// station (the newest grade it maps), so the copy says "Price updated" and
|
||||
/// never claims the selected grade itself was updated.
|
||||
///
|
||||
/// Past `FuelPriceFreshness.outdatedAge` a discreet note flags the information
|
||||
/// as possibly out of date. It is informational only — it gates nothing, and
|
||||
/// the station's own price row above it is unaffected.
|
||||
///
|
||||
/// Its own view rather than inlined into `StationDetailView`: that body sits at
|
||||
/// the compiler's type-checking limit, so one extra statement there fails the
|
||||
/// build. One statement here, one there.
|
||||
struct StationPriceAgeView: View {
|
||||
let assessment: FuelPriceFreshness.Assessment
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
LabeledContent("Price updated", value: assessment.updateAgeLabel)
|
||||
.monospacedDigit()
|
||||
if let notice = assessment.outdatedNotice {
|
||||
Text(notice)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel(notice)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,11 @@ struct StationsView: View {
|
||||
let topStationID: String?
|
||||
let location: Coordinate?
|
||||
let favouriteIDs: Set<String>
|
||||
let stationTapAction: StationTapAction
|
||||
let fuelSavingsSettings: FuelSavingsSettings
|
||||
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
|
||||
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
||||
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||
var onRefresh: () async -> Void = {}
|
||||
|
||||
@@ -29,6 +34,14 @@ struct StationsView: View {
|
||||
/// info button in the header — keeps the list focused on stations.
|
||||
@State private var showKey = false
|
||||
|
||||
/// The "How Best Value works" sheet. The explanation used to be a section in
|
||||
/// the Key sheet; it is its own sheet now, opened from a row at the bottom of
|
||||
/// the Key sheet and from the More info sheet.
|
||||
@State private var showBestValueInfo = 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
|
||||
@@ -37,7 +50,15 @@ struct StationsView: View {
|
||||
let miles = distanceUnit.displayMiles(stationLimit)
|
||||
let unit = distanceUnit.label(for: Double(miles))
|
||||
let fuel = selectedFuel.displayName
|
||||
let mode = sortMode == .closest ? "Closest" : "Cheapest"
|
||||
let mode: String
|
||||
switch sortMode {
|
||||
case .closest: mode = "Closest"
|
||||
case .cheapest: mode = "Cheapest"
|
||||
case .worthTheDrive: mode = "Best Value"
|
||||
}
|
||||
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)"
|
||||
@@ -46,11 +67,11 @@ struct StationsView: View {
|
||||
}
|
||||
if location != nil {
|
||||
if sortMode == .closest {
|
||||
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
||||
return "\(mode) \(fuel) stations — closest first, lowest price 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 {
|
||||
@@ -72,7 +93,7 @@ struct StationsView: View {
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
// In Closest mode the whole country is the pool — the
|
||||
// radius is meaningless ("nearest" must never return an
|
||||
// radius is meaningless ("closest" must never return an
|
||||
// empty state), so the picker is disabled but its value is
|
||||
// kept for when the user switches back to Cheapest.
|
||||
.disabled(sortMode == .closest)
|
||||
@@ -85,7 +106,7 @@ struct StationsView: View {
|
||||
|
||||
Section("Stations") {
|
||||
Picker("Sort by", selection: $sortMode) {
|
||||
ForEach(SortMode.allCases) { mode in
|
||||
ForEach(availableSortModes) { mode in
|
||||
Text(mode.displayName).tag(mode)
|
||||
}
|
||||
}
|
||||
@@ -95,7 +116,14 @@ struct StationsView: View {
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
|
||||
if isLoading {
|
||||
// Only offered while the feature is actually configured — a
|
||||
// stale stored sort mode must not keep the control on screen
|
||||
// for a switched-off feature.
|
||||
if sortMode == .worthTheDrive && fuelSavingsSettings.isConfigured {
|
||||
FuelSavingsAdjustmentView(amount: $fuelSavingsAmount)
|
||||
}
|
||||
|
||||
if isLoading && stations.isEmpty {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text("Fetching prices…")
|
||||
@@ -117,17 +145,7 @@ struct StationsView: View {
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
ForEach(Array(stations.prefix(visibleCount)), id: \.id) { station in
|
||||
StationRow(
|
||||
station: station,
|
||||
fuel: selectedFuel,
|
||||
location: location,
|
||||
distanceUnit: distanceUnit,
|
||||
priceDisplayStyle: priceDisplayStyle,
|
||||
baselinePrice: baselinePrice,
|
||||
isTopResult: station.id == topStationID,
|
||||
isFavourite: favouriteIDs.contains(station.id),
|
||||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
|
||||
)
|
||||
stationRow(station)
|
||||
}
|
||||
|
||||
if visibleCount < stations.count {
|
||||
@@ -171,6 +189,12 @@ struct StationsView: View {
|
||||
// large title at rest, scrolls it away and fades the compact
|
||||
// inline title in. No custom toolbar needed — the native mechanism
|
||||
// only renders text, which is exactly what we want here.
|
||||
// No numeric field is left on this screen — the Worth the Drive
|
||||
// fill-up control is a slider — so no keyboard should appear here.
|
||||
// The Done bar and interactive dismissal stay as the escape hatch
|
||||
// for any field that returns.
|
||||
.keyboardDoneButton()
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.navigationTitle("FuelBoard")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.onAppear {
|
||||
@@ -180,6 +204,30 @@ struct StationsView: View {
|
||||
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
|
||||
showKey = true
|
||||
}
|
||||
// QA hook: `-showBestValueInfo` opens the Key sheet and then the
|
||||
// explanation on top of it, so the copy can be screenshotted
|
||||
// without a tap. It has to chain: the explanation's `.sheet` lives
|
||||
// inside the Key sheet's content, so it cannot present while the
|
||||
// Key sheet is shut.
|
||||
if BestValueInfoQA.isRequested {
|
||||
showKey = true
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||
showBestValueInfo = true
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.task(id: stations.count) {
|
||||
#if DEBUG
|
||||
// QA hook: `-showDetail` opens the More info sheet for the first
|
||||
// listed station, so the sheet can be screenshotted without a
|
||||
// tap. Waits for the list to arrive first.
|
||||
guard ProcessInfo.processInfo.arguments.contains("-showDetail"),
|
||||
detailStation == nil,
|
||||
let first = stations.first else { return }
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
detailStation = first
|
||||
#endif
|
||||
}
|
||||
.toolbar {
|
||||
@@ -193,21 +241,44 @@ struct StationsView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showKey) {
|
||||
keySheet
|
||||
}
|
||||
.sheet(item: $detailStation) { station in
|
||||
detailSheet(for: station)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detailSheet(for station: FuelStation) -> some View {
|
||||
StationDetailView(
|
||||
station: station,
|
||||
location: location,
|
||||
distanceUnit: distanceUnit,
|
||||
selectedFuel: selectedFuel,
|
||||
fuelSavings: savingsForStation(station),
|
||||
fuelSavingsAmount: fuelSavingsAmount,
|
||||
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
||||
isFavourite: favouriteIDs.contains(station.id),
|
||||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
|
||||
)
|
||||
}
|
||||
|
||||
private var keySheet: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
VStack(spacing: 10) {
|
||||
keyCard(
|
||||
icon: "checkmark.circle.fill", color: .green,
|
||||
title: "Best value", blurb: "Within 1.5p of the cheapest",
|
||||
threshold: "≤1.5p")
|
||||
title: "Good price", blurb: "Within 1.5p of the cheapest",
|
||||
threshold: "0–1.5p")
|
||||
keyCard(
|
||||
icon: "equal.circle.fill", color: .orange,
|
||||
title: "Okay", blurb: "Within 4p of the cheapest",
|
||||
threshold: "≤4p")
|
||||
title: "Okay", blurb: "More than 1.5p, up to 4p above the cheapest",
|
||||
threshold: ">1.5–4p")
|
||||
keyCard(
|
||||
icon: "exclamationmark.circle.fill", color: .red,
|
||||
title: "Pricey", blurb: "More than 4p over the cheapest",
|
||||
title: "Pricey", blurb: "More than 4p above the cheapest",
|
||||
threshold: ">4p")
|
||||
Rectangle()
|
||||
.fill(Color(.separator))
|
||||
@@ -239,15 +310,27 @@ struct StationsView: View {
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.keyCardFill(color: .yellow)
|
||||
Text("Colours match each station's rating on the list")
|
||||
Text("Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||||
}
|
||||
|
||||
// The explanation is its own sheet now, opened from this
|
||||
// row. Gated exactly like the More info row and the Best
|
||||
// Value card itself: `isConfigured` is the same condition
|
||||
// `savingsForStation` uses, so the row appears and
|
||||
// disappears with the feature.
|
||||
if fuelSavingsSettings.isConfigured {
|
||||
Section {
|
||||
HowBestValueRow { showBestValueInfo = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Key")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -256,11 +339,36 @@ struct StationsView: View {
|
||||
Button("Done") { showKey = false }
|
||||
}
|
||||
}
|
||||
// Nested sheet: the Key sheet stays behind the explanation.
|
||||
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
|
||||
}
|
||||
.presentationDetents([.fraction(0.6)])
|
||||
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||
}
|
||||
}
|
||||
|
||||
private func stationRow(_ station: FuelStation) -> some View {
|
||||
StationRow(
|
||||
station: station,
|
||||
fuel: selectedFuel,
|
||||
location: location,
|
||||
distanceUnit: distanceUnit,
|
||||
priceDisplayStyle: priceDisplayStyle,
|
||||
baselinePrice: baselinePrice,
|
||||
isTopResult: station.id == topStationID,
|
||||
isFavourite: favouriteIDs.contains(station.id),
|
||||
tapAction: stationTapAction,
|
||||
fuelSavings: savingsForStation(station),
|
||||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
|
||||
onShowDetails: { detailStation = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
private var availableSortModes: [SortMode] {
|
||||
var modes: [SortMode] = [.closest, .cheapest]
|
||||
if fuelSavingsSettings.isConfigured {
|
||||
modes.append(.worthTheDrive)
|
||||
}
|
||||
return modes
|
||||
}
|
||||
|
||||
/// One price-rating card for the Key sheet: accent icon, title + blurb,
|
||||
@@ -276,6 +384,7 @@ struct StationsView: View {
|
||||
Text(LocalizedStringKey(blurb))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
HStack(spacing: 6) {
|
||||
@@ -317,17 +426,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 +440,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 {
|
||||
selection = fuel
|
||||
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 +468,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))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
|
||||
extension Notification.Name {
|
||||
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
|
||||
static let fuelBoardShowStationDetail = Notification.Name("FuelBoardShowStationDetail")
|
||||
/// Posted when a road-distance fill finishes, so the app's rows re-read the
|
||||
/// distance they are showing — those reads happen inside the row views, so
|
||||
/// nothing else would invalidate them.
|
||||
static let fuelBoardRoadDistancesUpdated = Notification.Name("FuelBoardRoadDistancesUpdated")
|
||||
/// Posted when "Accurate distances" is switched, so the browse layer can
|
||||
/// re-render and start a fill from where the user actually is.
|
||||
static let fuelBoardAccurateDistancesChanged = Notification.Name("FuelBoardAccurateDistancesChanged")
|
||||
/// Posted whenever the road-distance fill starts, finishes, or is gated.
|
||||
/// Settings → Debug subscribes so the tester can see why Approx. remains.
|
||||
static let fuelBoardRoadDistanceDebugUpdated = Notification.Name("FuelBoardRoadDistanceDebugUpdated")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WatchSyncManager: NSObject, WCSessionDelegate {
|
||||
static let shared = WatchSyncManager()
|
||||
|
||||
private var pendingSnapshotPush = false
|
||||
private var activationRequested = false
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
func activate() {
|
||||
guard WCSession.isSupported() else { return }
|
||||
let session = WCSession.default
|
||||
guard session.isWatchAppInstalled else { return }
|
||||
pendingSnapshotPush = true
|
||||
if session.delegate !== self {
|
||||
session.delegate = self
|
||||
}
|
||||
guard session.activationState != .activated else { return }
|
||||
guard !activationRequested else { return }
|
||||
activationRequested = true
|
||||
session.activate()
|
||||
}
|
||||
|
||||
func pushSnapshot() {
|
||||
guard WCSession.isSupported() else { return }
|
||||
let session = WCSession.default
|
||||
guard session.isWatchAppInstalled else {
|
||||
pendingSnapshotPush = false
|
||||
return
|
||||
}
|
||||
guard session.activationState == .activated else {
|
||||
pendingSnapshotPush = true
|
||||
activate()
|
||||
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)?) {
|
||||
Task { @MainActor in
|
||||
self.activationRequested = false
|
||||
}
|
||||
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
|
||||
self.activationRequested = false
|
||||
self.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)
|
||||
}
|
||||
}
|
||||
}
|
||||