Compare commits
56
Commits
@@ -1,4 +1,6 @@
|
|||||||
build/
|
build/
|
||||||
|
Builds/
|
||||||
|
build-*/
|
||||||
build-release/
|
build-release/
|
||||||
build-sim/
|
build-sim/
|
||||||
build-watch/
|
build-watch/
|
||||||
|
|||||||
+130
-28
@@ -1,6 +1,6 @@
|
|||||||
# FuelBoard App Store Submission Plan
|
# 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*
|
Companion to SECURITY.md (code security) — this file is the *review-gate*
|
||||||
checklist: what must happen, who owns it, and the timeline.
|
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.
|
on Apple's network see real data.
|
||||||
- ~~Always-location risk~~ → **Always is requested only when the user enables
|
- ~~Always-location risk~~ → **Always is requested only when the user enables
|
||||||
Alerts** (ProximityMonitor escalates WhenInUse → Always on alert opt-in).
|
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)
|
## 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
|
1. **[USER] Apple Developer Program membership** — the app has never been
|
||||||
signed (all builds are `CODE_SIGNING_ALLOWED=NO` + ad-hoc re-sign for
|
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
|
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
|
with the **App Group `group.com.apt.fuelboard`** capability AND the
|
||||||
**In-App Purchase** capability enabled (the widget + app-group cache
|
**In-App Purchase** capability enabled (the widget + app-group cache
|
||||||
depend on the group; the tip jar needs IAP).
|
depend on the group; the tip jar needs IAP).
|
||||||
3. ~~[ME] `PrivacyInfo.xcprivacy`~~ — **DONE 2026-08-16** (app + widget
|
3. ~~[ME] `PrivacyInfo.xcprivacy`~~ — **DONE** (app, widget, watch app, and
|
||||||
manifests added; UserDefaults `1C8F.1`, Location + Diagnostics declared).
|
watch wrapper manifests added; UserDefaults `1C8F.1`; app declares Location).
|
||||||
4. **[ME] Real Distribution signing** — Distribution certificate + App Store
|
4. **[ME] Real Distribution signing** — Distribution certificate + App Store
|
||||||
provisioning profile (or Xcode automatic signing once the account is in
|
provisioning profile (or Xcode automatic signing once the account is in
|
||||||
Xcode); then `xcodebuild archive` (no more `CODE_SIGNING_ALLOWED=NO`).
|
Xcode); then `xcodebuild archive` (no more `CODE_SIGNING_ALLOWED=NO`).
|
||||||
5. **[USER] Privacy policy URL** in App Store Connect — mandatory; the app
|
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)
|
## Required tasks — owner [ME] (technical, do after account exists)
|
||||||
|
|
||||||
- [x] **PrivacyInfo.xcprivacy** — app + widget manifests added (2026-08-16):
|
- [x] **PrivacyInfo.xcprivacy** — app, widget, watch app, and watch wrapper
|
||||||
UserDefaults reason `1C8F.1`; app also declares Location (linked, not
|
manifests added: UserDefaults reason `1C8F.1`; app also declares Location
|
||||||
tracking) + Diagnostics (not linked, not tracking — the dev beacon)
|
(linked, not tracking). Diagnostic network beacon removed with LAN relay.
|
||||||
- [ ] Distribution archive + TestFlight upload (needs Apple account)
|
- [ ] 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
|
- [x] **OGL attribution** — Settings → Data source footer now reads "Prices
|
||||||
use the UK Fuel Finder dataset (Open Government Licence) and refresh
|
use the UK Fuel Finder dataset (Open Government Licence) and refresh
|
||||||
up to twice a day." (2.3.1 hygiene)
|
up to twice a day." (2.3.1 hygiene)
|
||||||
- [x] **Out-of-UK empty state** — "No X stations within N miles" + UK-only
|
- [x] **Out-of-UK empty state** — "No X stations within N miles" + UK-only
|
||||||
explainer + "Browse all UK stations" button (switches to Closest)
|
explainer + "Browse all UK stations" button (switches to Closest)
|
||||||
- [ ] ~~Gate dev surface behind #if DEBUG~~ **DECLINED — kept in Release on
|
- [x] **Remove LAN relay fallback entirely** — no private IP, Local Network
|
||||||
purpose**: the user's home-testing installs the Release IPA and toggles
|
permission string, relay toggle, or diagnostic network beacon remains in
|
||||||
`fuelboard.relayFallback` via Settings → Debug (revealed only by tapping
|
the app/widget/watch binary. Hidden Debug still exists for local notification
|
||||||
About → Version ×5 — invisible to reviewers; no credentials involved).
|
and widget diagnostics only.
|
||||||
Gating it out of Release would break that workflow for zero review gain.
|
|
||||||
- [ ] Final pass: `swift test` green (95 tests), archive smoke test on device
|
- [ ] Final pass: `swift test` green (95 tests), archive smoke test on device
|
||||||
- [ ] ~~Capture screenshots from simulator~~ **RAW CAPTURES DONE (2026-08-16)** —
|
- [ ] ~~Capture screenshots from simulator~~ **RAW CAPTURES DONE (2026-08-16)** —
|
||||||
native 6.9″ 1320×2868 PNGs of all four tabs (Stations, Favourites,
|
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?)
|
category (Utilities), pricing (Free), availability (UK first?)
|
||||||
- [ ] Description, keywords, support URL, marketing URL, privacy policy URL
|
- [ ] Description, keywords, support URL, marketing URL, privacy policy URL
|
||||||
- [ ] Privacy nutrition labels questionnaire (location: yes, linked to user
|
- [ ] Privacy nutrition labels questionnaire (location: yes, linked to user
|
||||||
for alerts/nearby; diagnostics: yes — the widget-diag beacon, not
|
for alerts/nearby; no tracking; no ATT prompt needed)
|
||||||
linked, not used for tracking; no ATT prompt needed)
|
|
||||||
- [ ] Age rating questionnaire (4+; no objectionable content)
|
- [ ] Age rating questionnaire (4+; no objectionable content)
|
||||||
- [ ] Export compliance: HTTPS-only + keychain → standard encryption
|
- [ ] Export compliance: HTTPS-only + keychain → standard encryption
|
||||||
exemption (5A992a), no documentation needed
|
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
|
> 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.
|
> 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
|
> Location is used to (a) rank stations by distance and (b) power the
|
||||||
> optional Alerts feature: when the user enables alerts, the app geofences
|
> optional Alerts feature: when the user enables alerts, the app registers
|
||||||
> the cheapest station in their radius and sends a notification when they
|
> CoreLocation regions around the cheapest station in their radius and sends
|
||||||
> approach it. Always-location is requested only when the user enables
|
> a notification when they approach it. FuelBoard does not use persistent
|
||||||
> alerts. The app also provides a home-screen widget showing the current
|
> real-time background location tracking; it relies on iOS region monitoring
|
||||||
> cheapest station nearby, and a price-history Trends chart for starred
|
> and significant-change behaviour for optional alerts. Always-location is
|
||||||
> stations. All data is UK-wide (Fuel Finder covers England, Scotland, Wales
|
> requested only when the user enables alerts. The app also provides a
|
||||||
> and Northern Ireland); users outside the UK see an empty state explaining
|
> home-screen widget showing the current cheapest station nearby, and a
|
||||||
> this.
|
> 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)
|
## Review risks (know before submitting)
|
||||||
|
|
||||||
- **Always-location (5.1.1)** — the #1 question. The feature is visible
|
- **Always-location (5.1.1 / 2.5.4)** — the feature is visible (Alerts tab)
|
||||||
(Alerts tab) and opt-in; the review notes above say so explicitly.
|
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
|
- **Reviewer outside the UK** — empty radius; mitigated by the empty-state
|
||||||
task above + review notes.
|
task above + review notes.
|
||||||
- **"Cheapest" claims (2.3.1)** — mitigated by the OGL/footnote task.
|
- **"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.
|
glance. It is declared in the plist already.
|
||||||
- **Brand logos (Shell, BP, Tesco…)** — Simple Icons (CC0) + Wikimedia,
|
- **Brand logos (Shell, BP, Tesco…)** — Simple Icons (CC0) + Wikimedia,
|
||||||
nominative use (cf. GasBuddy); low risk, keep sources documented.
|
nominative use (cf. GasBuddy); low risk, keep sources documented.
|
||||||
- **GitHub raw as data source** — public data, no credentials; bundled dump
|
- **GitHub raw as data source** — public HTTPS data, no credentials or Local
|
||||||
covers any regional GitHub unreachability.
|
Network permission; bundled dump covers any regional GitHub unreachability.
|
||||||
|
|
||||||
## Timeline (best case)
|
## Timeline (best case)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 724 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 686 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 303 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 280 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 349 KiB |
+7
-10
@@ -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
|
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
|
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.
|
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.
|
Priority guide: P0 = next build, P1 = soon, P2 = later, P3 = maybe/idea.
|
||||||
Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
||||||
@@ -211,15 +211,12 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
|||||||
deploy key, 180-day retention) + history read side (Trends, P1) + **live chain
|
deploy key, 180-day retention) + history read side (Trends, P1) + **live chain
|
||||||
(`feature/p0-live-chain`)**: `MirrorFuelProvider` fetches CURRENT prices from
|
(`feature/p0-live-chain`)**: `MirrorFuelProvider` fetches CURRENT prices from
|
||||||
raw.githubusercontent (latest.json → history/<day>.json, app-group day-cache) →
|
raw.githubusercontent (latest.json → history/<day>.json, app-group day-cache) →
|
||||||
**GitHub FIRST everywhere** (alert checks + widget too — the relay is
|
**GitHub mirror only everywhere** (alert checks + widget too); no LAN relay
|
||||||
unreachable from any consumer phone and attempting it would fire the Local
|
fallback, private IP, Local Network permission, or diagnostic network beacon
|
||||||
Network prompt); LAN relay joins ONLY behind the hidden dev flag
|
ships in the app binary. Bundled REAL dump (`FuelBoardDump` dataset, refreshed
|
||||||
`fuelboard.relayFallback` (Settings → Debug, off by default); bundled REAL
|
by `scripts/refresh_bundled_dump.sh`) is the app's no-network last resort.
|
||||||
dump (`FuelBoardDump` dataset, refreshed by `scripts/refresh_bundled_dump.sh`)
|
Local Network permission removed entirely — data page is informational, replay
|
||||||
is the app's no-network last resort. `FuelBeacon` fire-and-forget X-Client app
|
on cellular can no longer hang. 107+ tests.
|
||||||
ping fires only with the dev flag on. **Local Network permission removed from
|
|
||||||
onboarding entirely** — data page is informational, replay on cellular can no
|
|
||||||
longer hang. 95 tests.
|
|
||||||
- [x] Switch dataset to govuk API data — official Fuel Finder OAuth API via
|
- [x] Switch dataset to govuk API data — official Fuel Finder OAuth API via
|
||||||
relay-api :8789 (source: api, ~8,012 stations); app baseURL → :8789; credentials
|
relay-api :8789 (source: api, ~8,012 stations); app baseURL → :8789; credentials
|
||||||
live in relay `.env` only, never in the IPA
|
live in relay `.env` only, never in the IPA
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.1</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
@@ -33,8 +33,6 @@
|
|||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<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>
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
<string>FuelBoard uses Always location to alert you when you approach the cheapest station nearby.</string>
|
<string>FuelBoard uses Always location to alert you when you approach the cheapest station nearby.</string>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
@@ -48,7 +46,6 @@
|
|||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>fetch</string>
|
<string>fetch</string>
|
||||||
<string>location</string>
|
|
||||||
</array>
|
</array>
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict/>
|
<dict/>
|
||||||
|
|||||||
@@ -10,6 +10,17 @@
|
|||||||
ADCE5B5A3033A32C008C131C /* FuelBoardWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
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, ); }; };
|
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
BB0000000000000000000001 /* FuelBoardWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* FuelBoardWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
BB0000000000000000000001 /* FuelBoardWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BB0000000000000000000002 /* FuelBoardWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
FBA000000000000000000001 /* DataRefreshMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000101 /* DataRefreshMode.swift */; };
|
||||||
|
FBA000000000000000000002 /* FuelHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000102 /* FuelHistory.swift */; };
|
||||||
|
FBA000000000000000000003 /* FuelPriceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000103 /* FuelPriceProvider.swift */; };
|
||||||
|
FBA000000000000000000004 /* FuelStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000104 /* FuelStore.swift */; };
|
||||||
|
FBA000000000000000000005 /* MirrorFuelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000105 /* MirrorFuelProvider.swift */; };
|
||||||
|
FBA000000000000000000006 /* SiriCheapestLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000106 /* SiriCheapestLookup.swift */; };
|
||||||
|
FBA000000000000000000007 /* WidgetLocationFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000107 /* WidgetLocationFetcher.swift */; };
|
||||||
|
FBA000000000000000000008 /* FuelBoardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000108 /* FuelBoardTests.swift */; };
|
||||||
|
FBA000000000000000000009 /* FuelHistoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA000000000000000000109 /* FuelHistoryTests.swift */; };
|
||||||
|
FBA00000000000000000000A /* LiveChainTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBA00000000000000000010A /* LiveChainTests.swift */; };
|
||||||
|
FBA00000000000000000000B /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FBA00000000000000000010B /* XCTest.framework */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -37,17 +48,6 @@
|
|||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
ADCE5B653033A32D008C131C /* Embed App Extensions */ = {
|
|
||||||
isa = PBXCopyFilesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
dstPath = "";
|
|
||||||
dstSubfolderSpec = 13;
|
|
||||||
files = (
|
|
||||||
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */,
|
|
||||||
);
|
|
||||||
name = "Embed App Extensions";
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
ADCE5B5B3033A32C008C131C /* Embed Watch Content */ = {
|
ADCE5B5B3033A32C008C131C /* Embed Watch Content */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -59,6 +59,17 @@
|
|||||||
name = "Embed Watch Content";
|
name = "Embed Watch Content";
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
ADCE5B653033A32D008C131C /* Embed App Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
ADCE5B623033A32D008C131C /* FuelBoardWatch Watch App Extension.appex in Embed App Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed App Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
BB0000000000000000000006 /* Embed App Extensions */ = {
|
BB0000000000000000000006 /* Embed App Extensions */ = {
|
||||||
isa = PBXCopyFilesBuildPhase;
|
isa = PBXCopyFilesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -73,10 +84,23 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
AD22FCFF30682512009859D4 /* FuelBoardTips.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = FuelBoardTips.storekit; sourceTree = "<group>"; };
|
||||||
|
AD64FC4C30739A4B008F27A9 /* FuelBoardSharedTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FuelBoardSharedTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FuelBoardWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
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; };
|
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "FuelBoardWatch Watch App Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
BB0000000000000000000002 /* FuelBoardWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FuelBoardWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
BB0000000000000000000002 /* FuelBoardWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = FuelBoardWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
BB0000000000000000000007 /* FuelBoard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FuelBoard.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
BB0000000000000000000007 /* FuelBoard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FuelBoard.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
FBA000000000000000000101 /* DataRefreshMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataRefreshMode.swift; sourceTree = "<group>"; };
|
||||||
|
FBA000000000000000000102 /* FuelHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelHistory.swift; sourceTree = "<group>"; };
|
||||||
|
FBA000000000000000000103 /* FuelPriceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelPriceProvider.swift; sourceTree = "<group>"; };
|
||||||
|
FBA000000000000000000104 /* FuelStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelStore.swift; sourceTree = "<group>"; };
|
||||||
|
FBA000000000000000000105 /* MirrorFuelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MirrorFuelProvider.swift; sourceTree = "<group>"; };
|
||||||
|
FBA000000000000000000106 /* SiriCheapestLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiriCheapestLookup.swift; sourceTree = "<group>"; };
|
||||||
|
FBA000000000000000000107 /* WidgetLocationFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetLocationFetcher.swift; sourceTree = "<group>"; };
|
||||||
|
FBA000000000000000000108 /* FuelBoardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelBoardTests.swift; sourceTree = "<group>"; };
|
||||||
|
FBA000000000000000000109 /* FuelHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FuelHistoryTests.swift; sourceTree = "<group>"; };
|
||||||
|
FBA00000000000000000010A /* LiveChainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveChainTests.swift; sourceTree = "<group>"; };
|
||||||
|
FBA00000000000000000010B /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = System/Library/Frameworks/XCTest.framework; sourceTree = SDKROOT; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
@@ -136,18 +160,37 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
FBA000000000000000000302 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
FBA00000000000000000000B /* XCTest.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
AD64FC4B30739A4B008F27A9 /* Recovered References */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
FBA00000000000000000010B /* XCTest.framework */,
|
||||||
|
);
|
||||||
|
name = "Recovered References";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
BB000000000000000000000D = {
|
BB000000000000000000000D = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
FBA000000000000000000200 /* FuelBoardTests */,
|
||||||
|
AD22FCFF30682512009859D4 /* FuelBoardTips.storekit */,
|
||||||
BB0000000000000000000008 /* FuelBoard */,
|
BB0000000000000000000008 /* FuelBoard */,
|
||||||
BB0000000000000000000009 /* FuelBoardWidgets */,
|
BB0000000000000000000009 /* FuelBoardWidgets */,
|
||||||
BB000000000000000000000A /* Shared */,
|
BB000000000000000000000A /* Shared */,
|
||||||
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */,
|
ADCE5B663033A32D008C131C /* FuelBoardWatch Wrapper */,
|
||||||
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
|
ADCE5B503033A329008C131C /* FuelBoardWatch Watch App */,
|
||||||
BB000000000000000000000E /* Products */,
|
BB000000000000000000000E /* Products */,
|
||||||
|
AD64FC4B30739A4B008F27A9 /* Recovered References */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -158,16 +201,52 @@
|
|||||||
BB0000000000000000000002 /* FuelBoardWidgets.appex */,
|
BB0000000000000000000002 /* FuelBoardWidgets.appex */,
|
||||||
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */,
|
ADCE5B4F3033A329008C131C /* FuelBoardWatch Watch App.app */,
|
||||||
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */,
|
ADCE5B633033A32D008C131C /* FuelBoardWatch Watch App Extension.appex */,
|
||||||
|
AD64FC4C30739A4B008F27A9 /* FuelBoardSharedTests.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
FBA000000000000000000200 /* FuelBoardTests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
FBA000000000000000000201 /* Sources */,
|
||||||
|
FBA000000000000000000202 /* Tests */,
|
||||||
|
);
|
||||||
|
path = FuelBoardTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
FBA000000000000000000201 /* Sources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
FBA000000000000000000101 /* DataRefreshMode.swift */,
|
||||||
|
FBA000000000000000000102 /* FuelHistory.swift */,
|
||||||
|
FBA000000000000000000103 /* FuelPriceProvider.swift */,
|
||||||
|
FBA000000000000000000104 /* FuelStore.swift */,
|
||||||
|
FBA000000000000000000105 /* MirrorFuelProvider.swift */,
|
||||||
|
FBA000000000000000000106 /* SiriCheapestLookup.swift */,
|
||||||
|
FBA000000000000000000107 /* WidgetLocationFetcher.swift */,
|
||||||
|
);
|
||||||
|
name = Sources;
|
||||||
|
path = Sources/FuelBoardShared;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
FBA000000000000000000202 /* Tests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
FBA000000000000000000108 /* FuelBoardTests.swift */,
|
||||||
|
FBA000000000000000000109 /* FuelHistoryTests.swift */,
|
||||||
|
FBA00000000000000000010A /* LiveChainTests.swift */,
|
||||||
|
);
|
||||||
|
name = Tests;
|
||||||
|
path = Tests/FuelBoardSharedTests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App */ = {
|
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */;
|
buildConfigurationList = ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App Extension" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
ADCE5B4B3033A329008C131C /* Sources */,
|
ADCE5B4B3033A329008C131C /* Sources */,
|
||||||
ADCE5B4C3033A329008C131C /* Frameworks */,
|
ADCE5B4C3033A329008C131C /* Frameworks */,
|
||||||
@@ -260,6 +339,23 @@
|
|||||||
productReference = BB0000000000000000000007 /* FuelBoard.app */;
|
productReference = BB0000000000000000000007 /* FuelBoard.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
|
FBA000000000000000000300 /* FuelBoardSharedTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = FBA000000000000000000400 /* Build configuration list for PBXNativeTarget "FuelBoardSharedTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
FBA000000000000000000301 /* Sources */,
|
||||||
|
FBA000000000000000000302 /* Frameworks */,
|
||||||
|
FBA000000000000000000303 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = FuelBoardSharedTests;
|
||||||
|
productName = FuelBoardSharedTests;
|
||||||
|
productReference = AD64FC4C30739A4B008F27A9 /* FuelBoardSharedTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -278,6 +374,11 @@
|
|||||||
};
|
};
|
||||||
BB000000000000000000000F = {
|
BB000000000000000000000F = {
|
||||||
CreatedOnToolsVersion = 16.0;
|
CreatedOnToolsVersion = 16.0;
|
||||||
|
SystemCapabilities = {
|
||||||
|
com.apple.InAppPurchase = {
|
||||||
|
enabled = 1;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -299,6 +400,7 @@
|
|||||||
BB0000000000000000000005 /* FuelBoardWidgets */,
|
BB0000000000000000000005 /* FuelBoardWidgets */,
|
||||||
ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */,
|
ADCE5B673033A32D008C131C /* FuelBoardWatch Watch App */,
|
||||||
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */,
|
ADCE5B4E3033A329008C131C /* FuelBoardWatch Watch App Extension */,
|
||||||
|
FBA000000000000000000300 /* FuelBoardSharedTests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -332,6 +434,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
FBA000000000000000000303 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -356,6 +465,23 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
FBA000000000000000000301 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
FBA000000000000000000001 /* DataRefreshMode.swift in Sources */,
|
||||||
|
FBA000000000000000000002 /* FuelHistory.swift in Sources */,
|
||||||
|
FBA000000000000000000003 /* FuelPriceProvider.swift in Sources */,
|
||||||
|
FBA000000000000000000004 /* FuelStore.swift in Sources */,
|
||||||
|
FBA000000000000000000005 /* MirrorFuelProvider.swift in Sources */,
|
||||||
|
FBA000000000000000000006 /* SiriCheapestLookup.swift in Sources */,
|
||||||
|
FBA000000000000000000007 /* WidgetLocationFetcher.swift in Sources */,
|
||||||
|
FBA000000000000000000008 /* FuelBoardTests.swift in Sources */,
|
||||||
|
FBA000000000000000000009 /* FuelHistoryTests.swift in Sources */,
|
||||||
|
FBA00000000000000000000A /* LiveChainTests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
@@ -405,8 +531,9 @@
|
|||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 26;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -418,14 +545,13 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
INFOPLIST_FILE = "Config/WatchExtension-Info.plist";
|
||||||
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.1.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -471,8 +597,9 @@
|
|||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 26;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -484,14 +611,13 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/WatchApp.entitlements;
|
INFOPLIST_FILE = "Config/WatchExtension-Info.plist";
|
||||||
INFOPLIST_FILE = Config/WatchExtension-Info.plist;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.1.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp.watchkitextension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -537,7 +663,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 26;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -549,13 +675,13 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Config/WatchWrapper-Info.plist;
|
INFOPLIST_FILE = "Config/WatchWrapper-Info.plist";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.1.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -602,7 +728,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 26;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -614,13 +740,13 @@
|
|||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = Config/WatchWrapper-Info.plist;
|
INFOPLIST_FILE = "Config/WatchWrapper-Info.plist";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.1.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.watchapp;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -698,8 +824,9 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 26;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||||
@@ -707,9 +834,10 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
@@ -721,8 +849,9 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/App.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 26;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/App-Info.plist";
|
INFOPLIST_FILE = "Config/App-Info.plist";
|
||||||
@@ -730,9 +859,10 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = 1;
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
@@ -745,7 +875,7 @@
|
|||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 26;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||||
@@ -754,7 +884,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -770,7 +900,7 @@
|
|||||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Config/Widget.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 26;
|
||||||
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
INFOPLIST_FILE = "Config/Widget-Info.plist";
|
||||||
@@ -779,7 +909,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
MARKETING_VERSION = 1.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.widgets;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -789,10 +919,56 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
FBA000000000000000000401 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = FuelBoardSharedTests;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.sharedtests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
FBA000000000000000000402 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 23;
|
||||||
|
DEVELOPMENT_TEAM = F3BE6NE7U3;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = FuelBoardSharedTests;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@loader_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.apt.fuelboard.sharedtests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App" */ = {
|
ADCE5B593033A32B008C131C /* Build configuration list for PBXNativeTarget "FuelBoardWatch Watch App Extension" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
ADCE5B573033A32B008C131C /* Debug */,
|
ADCE5B573033A32B008C131C /* Debug */,
|
||||||
@@ -837,6 +1013,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
FBA000000000000000000400 /* Build configuration list for PBXNativeTarget "FuelBoardSharedTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
FBA000000000000000000401 /* Debug */,
|
||||||
|
FBA000000000000000000402 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
};
|
};
|
||||||
rootObject = BB0000000000000000000004 /* Project object */;
|
rootObject = BB0000000000000000000004 /* Project object */;
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2600"
|
||||||
|
version = "1.8">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BB000000000000000000000F"
|
||||||
|
BuildableName = "FuelBoard.app"
|
||||||
|
BlueprintName = "FuelBoard"
|
||||||
|
ReferencedContainer = "container:FuelBoard.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "FBA000000000000000000300"
|
||||||
|
BuildableName = "FuelBoardSharedTests.xctest"
|
||||||
|
BlueprintName = "FuelBoardSharedTests"
|
||||||
|
ReferencedContainer = "container:FuelBoard.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
<StoreKitConfigurationFileReference
|
||||||
|
identifier = "../../../FuelBoardTips.storekit">
|
||||||
|
</StoreKitConfigurationFileReference>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
language = "en"
|
||||||
|
region = "GB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersion = "1.0"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BB000000000000000000000F"
|
||||||
|
BuildableName = "FuelBoard.app"
|
||||||
|
BlueprintName = "FuelBoard"
|
||||||
|
ReferencedContainer = "container:FuelBoard.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersion = "1.0"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "BB000000000000000000000F"
|
||||||
|
BuildableName = "FuelBoard.app"
|
||||||
|
BlueprintName = "FuelBoard"
|
||||||
|
ReferencedContainer = "container:FuelBoard.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
+59
-31
@@ -12,6 +12,9 @@ struct AlertsView: View {
|
|||||||
@Binding var enabled: Bool
|
@Binding var enabled: Bool
|
||||||
@Binding var radius: Double // stored in km (monitor + storage)
|
@Binding var radius: Double // stored in km (monitor + storage)
|
||||||
@Binding var fuel: FuelType // the fuel alerts monitor for
|
@Binding var fuel: FuelType // the fuel alerts monitor for
|
||||||
|
@Binding var alertMode: AlertMode
|
||||||
|
@Binding var bestValueThresholdGBP: Double
|
||||||
|
let bestValueConfigured: Bool
|
||||||
@Binding var favouriteDropEnabled: Bool
|
@Binding var favouriteDropEnabled: Bool
|
||||||
@Binding var favouriteDropFuel: FuelType
|
@Binding var favouriteDropFuel: FuelType
|
||||||
/// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
|
/// Whether alerts mirror the Stations-tab search distance (capped at 8 mi).
|
||||||
@@ -38,7 +41,7 @@ struct AlertsView: View {
|
|||||||
/// "5 miles" or "8 km" — for the "Follow search" rows.
|
/// "5 miles" or "8 km" — for the "Follow search" rows.
|
||||||
private var searchInUnitLabel: String {
|
private var searchInUnitLabel: String {
|
||||||
let shown = distanceUnit.displayMiles(stationLimit) // stationLimit is always miles
|
let shown = distanceUnit.displayMiles(stationLimit) // stationLimit is always miles
|
||||||
return "\(shown) \(distanceUnit.label(for: Double(shown)))"
|
return String(format: String(localized: "%lld %@", comment: "Distance label"), shown, distanceUnit.label(for: Double(shown)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A miles value (5/10/15) shown truthfully in the user's unit, e.g.
|
/// A miles value (5/10/15) shown truthfully in the user's unit, e.g.
|
||||||
@@ -46,7 +49,7 @@ struct AlertsView: View {
|
|||||||
/// what the option really means.
|
/// what the option really means.
|
||||||
private func distanceLabel(forMiles miles: Int) -> String {
|
private func distanceLabel(forMiles miles: Int) -> String {
|
||||||
let shown = distanceUnit.displayMiles(miles)
|
let shown = distanceUnit.displayMiles(miles)
|
||||||
return "\(shown) \(distanceUnit.label(for: Double(shown)))"
|
return String(format: String(localized: "%lld %@", comment: "Distance label"), shown, distanceUnit.label(for: Double(shown)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Situational guidance for the currently-selected alert radius, shown
|
/// Situational guidance for the currently-selected alert radius, shown
|
||||||
@@ -57,23 +60,23 @@ struct AlertsView: View {
|
|||||||
private var alertRadiusGuidance: String {
|
private var alertRadiusGuidance: String {
|
||||||
switch alertChoice.wrappedValue {
|
switch alertChoice.wrappedValue {
|
||||||
case .followSearch:
|
case .followSearch:
|
||||||
return "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing)."
|
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):
|
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
|
let body: String
|
||||||
switch value {
|
switch value {
|
||||||
case 1:
|
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:
|
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:
|
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:
|
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:
|
default:
|
||||||
body = "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires."
|
body = String(localized: "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires.", comment: "Alert radius guidance")
|
||||||
}
|
}
|
||||||
return "\(optionLabel) — \(body)"
|
return String(format: String(localized: "%@ — %@", comment: "Alert radius guidance with option label"), optionLabel, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,74 +137,99 @@ struct AlertsView: View {
|
|||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
Toggle("Cheapest favourite", isOn: $favouriteDropEnabled)
|
Toggle(String(localized: "Cheapest favourite", comment: "Favourite price-drop alert toggle"), isOn: $favouriteDropEnabled)
|
||||||
Picker("Fuel", selection: $favouriteDropFuel) {
|
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $favouriteDropFuel) {
|
||||||
ForEach(FuelType.allCases) { fuel in
|
ForEach(FuelType.allCases) { fuel in
|
||||||
Text(fuel.displayName).tag(fuel)
|
Text(fuel.displayName).tag(fuel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button("Check now", action: onCheckFavouriteDropNow)
|
Button(String(localized: "Check now", comment: "Manual favourite price-drop check button"), action: onCheckFavouriteDropNow)
|
||||||
.disabled(!favouriteDropEnabled)
|
.disabled(!favouriteDropEnabled)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Price-drop alerts")
|
Text(String(localized: "Price-drop alerts", comment: "Favourite price-drop alerts section title"))
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise.")
|
Text(String(localized: "Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise.", comment: "Favourite price-drop alerts section footer"))
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle("Cheapest station", isOn: $enabled)
|
Toggle(String(localized: "Cheapest station", comment: "Proximity alert toggle"), isOn: $enabled)
|
||||||
Picker("Fuel", selection: $fuel) {
|
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
|
ForEach(FuelType.allCases) { fuel in
|
||||||
Text(fuel.displayName).tag(fuel)
|
Text(fuel.displayName).tag(fuel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Picker("Radius", selection: alertChoice) {
|
Picker(String(localized: "Radius", comment: "Alert radius picker title"), selection: alertChoice) {
|
||||||
Text("Follow search (\(searchInUnitLabel))").tag(AlertRadiusChoice.followSearch)
|
Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(AlertRadiusChoice.followSearch)
|
||||||
ForEach(FuelStore.alertRadiusOptions, id: \.self) { value in
|
ForEach(FuelStore.alertRadiusOptions, id: \.self) { value in
|
||||||
Text("\(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)
|
Text(alertRadiusGuidance)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
if alertMode == .bestValue {
|
||||||
|
Picker(String(localized: "Minimum saving", comment: "Best Value alert threshold picker title"), selection: $bestValueThresholdGBP) {
|
||||||
|
ForEach(FuelStore.bestValueAlertThresholdOptionsGBP, id: \.self) { value in
|
||||||
|
Text(FuelSavingsFormat.money(value)).tag(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !bestValueConfigured {
|
||||||
|
Text(String(localized: "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.", comment: "Best Value alert setup hint"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
if enabled && monitoredCount == 0 {
|
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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Proximity alerts")
|
Text(String(localized: "Proximity alerts", comment: "Proximity alerts section title"))
|
||||||
} footer: {
|
} 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 {
|
if enabled, let lastAlert {
|
||||||
Section("Last alert") {
|
Section(String(localized: "Last alert", comment: "Last alert section title")) {
|
||||||
Label(lastAlert, systemImage: "bell.badge.fill")
|
Label(lastAlert, systemImage: "bell.badge.fill")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle("Cheapest station", isOn: $liveActivityEnabled)
|
Toggle(String(localized: "Cheapest station", comment: "Live Activity toggle"), isOn: $liveActivityEnabled)
|
||||||
Picker("Fuel", selection: $liveActivityFuel) {
|
Picker(String(localized: "Fuel", comment: "Fuel picker title"), selection: $liveActivityFuel) {
|
||||||
ForEach(FuelType.allCases) { fuel in
|
ForEach(FuelType.allCases) { fuel in
|
||||||
Text(fuel.displayName).tag(fuel)
|
Text(fuel.displayName).tag(fuel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Picker("Distance", selection: laChoice) {
|
Picker(String(localized: "Distance", comment: "Distance picker title"), selection: laChoice) {
|
||||||
Text("Follow search (\(searchInUnitLabel))").tag(LADistanceChoice.followSearch)
|
Text(String(format: String(localized: "Follow search (%@)", comment: "Follow current station search distance option"), searchInUnitLabel)).tag(LADistanceChoice.followSearch)
|
||||||
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
|
ForEach(FuelStore.stationRadiusOptions, id: \.self) { miles in
|
||||||
Text(distanceLabel(forMiles: miles)).tag(LADistanceChoice.fixed(miles))
|
Text(distanceLabel(forMiles: miles)).tag(LADistanceChoice.fixed(miles))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
Text("Live Activity")
|
Text(String(localized: "Live Activity", comment: "Live Activity section title"))
|
||||||
} footer: {
|
} 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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
"filename" : "AppIconArtwork.png",
|
"filename" : "AppIconArtwork.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
"filename" : "AppIconPreview.png",
|
"filename" : "AppIconPreview.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|||||||
@@ -4,6 +4,14 @@
|
|||||||
"filename" : "AppIconPreviewDark.png",
|
"filename" : "AppIconPreviewDark.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -4,6 +4,14 @@
|
|||||||
"filename" : "PreviewWallpaper.jpg",
|
"filename" : "PreviewWallpaper.jpg",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_applegreen_2x.png",
|
"filename" : "brand_applegreen_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_asda_2x.png",
|
"filename" : "brand_asda_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_bp_2x.png",
|
"filename" : "brand_bp_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_circle_k_2x.png",
|
"filename" : "brand_circle_k_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_essar_2x.png",
|
"filename" : "brand_essar_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_esso_2x.png",
|
"filename" : "brand_esso_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_gleaner_2x.png",
|
"filename" : "brand_gleaner_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_gulf_2x.png",
|
"filename" : "brand_gulf_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_highland_fuels_2x.png",
|
"filename" : "brand_highland_fuels_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_jet_2x.png",
|
"filename" : "brand_jet_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_maxol_2x.png",
|
"filename" : "brand_maxol_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_morrisons_2x.png",
|
"filename" : "brand_morrisons_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_murco_2x.png",
|
"filename" : "brand_murco_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_sainsburys_2x.png",
|
"filename" : "brand_sainsburys_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_shell_2x.png",
|
"filename" : "brand_shell_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_spar_2x.png",
|
"filename" : "brand_spar_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_tesco_2x.png",
|
"filename" : "brand_tesco_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_texaco_2x.png",
|
"filename" : "brand_texaco_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_the_co_operative_2x.png",
|
"filename" : "brand_the_co_operative_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_valero_2x.png",
|
"filename" : "brand_valero_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"filename" : "brand_welcome_break_2x.png",
|
"filename" : "brand_welcome_break_2x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1008
-80
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,18 @@ struct FavouritesView: View {
|
|||||||
let location: Coordinate?
|
let location: Coordinate?
|
||||||
let distanceUnit: DistanceUnit
|
let distanceUnit: DistanceUnit
|
||||||
let priceDisplayStyle: PriceDisplayStyle
|
let priceDisplayStyle: PriceDisplayStyle
|
||||||
|
let stationTapAction: StationTapAction
|
||||||
|
/// Fuel Savings config. When it is configured this tab shows the SAME
|
||||||
|
/// fill-up control and per-row estimate as the Stations list: one session
|
||||||
|
/// amount is shared, so adjusting it in either list updates both, and it
|
||||||
|
/// still never touches the Settings default.
|
||||||
|
let fuelSavingsSettings: FuelSavingsSettings
|
||||||
|
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
|
||||||
|
/// Estimated saving for a favourite, against the nearest eligible station —
|
||||||
|
/// the same baseline and definition the Stations list uses.
|
||||||
|
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
||||||
|
var savingsBaselineStationName: String?
|
||||||
|
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
||||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||||
/// Persists a reordered favourites array (after drag-and-drop).
|
/// Persists a reordered favourites array (after drag-and-drop).
|
||||||
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
||||||
@@ -37,6 +49,9 @@ struct FavouritesView: View {
|
|||||||
/// Trends sheet (price history chart) presentation state.
|
/// Trends sheet (price history chart) presentation state.
|
||||||
@State private var showTrends = false
|
@State private var showTrends = false
|
||||||
|
|
||||||
|
/// Station picked for the More-info sheet (tap action = showDetails).
|
||||||
|
@State private var detailStation: FuelStation?
|
||||||
|
|
||||||
private var activeFuel: FuelType {
|
private var activeFuel: FuelType {
|
||||||
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
|
availableFuels.contains(fuel) ? fuel : (availableFuels.first ?? .e10)
|
||||||
}
|
}
|
||||||
@@ -65,6 +80,12 @@ struct FavouritesView: View {
|
|||||||
location: Coordinate?,
|
location: Coordinate?,
|
||||||
distanceUnit: DistanceUnit,
|
distanceUnit: DistanceUnit,
|
||||||
priceDisplayStyle: PriceDisplayStyle,
|
priceDisplayStyle: PriceDisplayStyle,
|
||||||
|
stationTapAction: StationTapAction,
|
||||||
|
fuelSavingsSettings: FuelSavingsSettings = .defaults,
|
||||||
|
fuelSavingsAmount: Binding<FuelSavingsFillAmount>,
|
||||||
|
savingsForStation: @escaping (FuelStation) -> FuelSavingsAvailability? = { _ in nil },
|
||||||
|
savingsBaselineStationName: String? = nil,
|
||||||
|
journeyDiagnosticsForStation: @escaping (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil },
|
||||||
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
||||||
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
||||||
onHistoryUnavailable: (() -> Void)? = nil,
|
onHistoryUnavailable: (() -> Void)? = nil,
|
||||||
@@ -74,6 +95,12 @@ struct FavouritesView: View {
|
|||||||
self.location = location
|
self.location = location
|
||||||
self.distanceUnit = distanceUnit
|
self.distanceUnit = distanceUnit
|
||||||
self.priceDisplayStyle = priceDisplayStyle
|
self.priceDisplayStyle = priceDisplayStyle
|
||||||
|
self.stationTapAction = stationTapAction
|
||||||
|
self.fuelSavingsSettings = fuelSavingsSettings
|
||||||
|
_fuelSavingsAmount = fuelSavingsAmount
|
||||||
|
self.savingsForStation = savingsForStation
|
||||||
|
self.savingsBaselineStationName = savingsBaselineStationName
|
||||||
|
self.journeyDiagnosticsForStation = journeyDiagnosticsForStation
|
||||||
self.onToggleFavourite = onToggleFavourite
|
self.onToggleFavourite = onToggleFavourite
|
||||||
self.onReorder = onReorder
|
self.onReorder = onReorder
|
||||||
self.onHistoryUnavailable = onHistoryUnavailable
|
self.onHistoryUnavailable = onHistoryUnavailable
|
||||||
@@ -100,9 +127,9 @@ struct FavouritesView: View {
|
|||||||
Image(systemName: "star")
|
Image(systemName: "star")
|
||||||
.font(.system(size: 40))
|
.font(.system(size: 40))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Text("No favourites yet")
|
Text(String(localized: "No favourites yet", comment: "Favourites empty-state title"))
|
||||||
.font(.headline)
|
.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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -111,13 +138,24 @@ struct FavouritesView: View {
|
|||||||
.padding(.vertical, 24)
|
.padding(.vertical, 24)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Section("Fuel type") {
|
Section(String(localized: "Fuel type", comment: "Fuel type section title")) {
|
||||||
FuelTypeSegmentedPicker(
|
FuelTypeSegmentedPicker(
|
||||||
selection: $fuel,
|
selection: $fuel,
|
||||||
fuels: availableFuels
|
fuels: availableFuels
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if fuelSavingsSettings.isConfigured {
|
||||||
|
Section {
|
||||||
|
FuelSavingsAdjustmentView(
|
||||||
|
amount: $fuelSavingsAmount,
|
||||||
|
referencePricePencePerLitre: cheapestPrice
|
||||||
|
)
|
||||||
|
} footer: {
|
||||||
|
Text(String(localized: "Savings below are estimated against the nearest station with a price for this fuel — the same baseline as the Stations list.", comment: "Favourites fuel-savings explanation"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
ForEach(Array(ordered.enumerated()), id: \.element.id) { index, station in
|
ForEach(Array(ordered.enumerated()), id: \.element.id) { index, station in
|
||||||
StationRow(
|
StationRow(
|
||||||
@@ -127,28 +165,33 @@ struct FavouritesView: View {
|
|||||||
distanceUnit: distanceUnit,
|
distanceUnit: distanceUnit,
|
||||||
priceDisplayStyle: priceDisplayStyle,
|
priceDisplayStyle: priceDisplayStyle,
|
||||||
baselinePrice: cheapestPrice,
|
baselinePrice: cheapestPrice,
|
||||||
isTopResult: index == 0,
|
isTopResult: station.id == cheapest?.id,
|
||||||
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
isFavourite: activeFuelFavouriteIDs.contains(station.id),
|
||||||
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
|
topBadgeText: "CHEAPEST",
|
||||||
|
tapAction: stationTapAction,
|
||||||
|
fuelSavings: savingsForStation(station),
|
||||||
|
fuelSavingsAmount: fuelSavingsAmount,
|
||||||
|
onToggleFavourite: { onToggleFavourite(station, activeFuel) },
|
||||||
|
onShowDetails: { detailStation = $0 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onMove(perform: moveFavourite)
|
.onMove(perform: moveFavourite)
|
||||||
} header: {
|
} header: {
|
||||||
Text("\(activeFuel.displayName) favourites")
|
Text(String(format: String(localized: "%@ favourites", comment: "Favourites section title for fuel type"), activeFuel.displayName))
|
||||||
} footer: {
|
} 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 {
|
if let cheapest = cheapest {
|
||||||
Section {
|
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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Favourites")
|
.navigationTitle(String(localized: "Favourites", comment: "Favourites tab title"))
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if !favourites.isEmpty {
|
if !favourites.isEmpty {
|
||||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||||
@@ -156,7 +199,7 @@ struct FavouritesView: View {
|
|||||||
showTrends = true
|
showTrends = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "chart.xyaxis.line")
|
Image(systemName: "chart.xyaxis.line")
|
||||||
.accessibilityLabel("Trends")
|
.accessibilityLabel(String(localized: "Trends", comment: "Trends button accessibility label"))
|
||||||
}
|
}
|
||||||
EditButton()
|
EditButton()
|
||||||
}
|
}
|
||||||
@@ -171,6 +214,21 @@ struct FavouritesView: View {
|
|||||||
onHistoryRecovered: onHistoryRecovered
|
onHistoryRecovered: onHistoryRecovered
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.sheet(item: $detailStation) { station in
|
||||||
|
StationDetailView(
|
||||||
|
station: station,
|
||||||
|
location: location,
|
||||||
|
distanceUnit: distanceUnit,
|
||||||
|
fuelSavings: savingsForStation(station),
|
||||||
|
fuelSavingsAmount: fuelSavingsAmount,
|
||||||
|
savingsBaselineStationName: savingsBaselineStationName,
|
||||||
|
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
||||||
|
// Every station listed here IS a favourite for the tab's
|
||||||
|
// fuel, so the star starts filled; tapping it unstars.
|
||||||
|
isFavourite: true,
|
||||||
|
onToggleFavourite: { onToggleFavourite(station, activeFuel) }
|
||||||
|
)
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// QA hook: launch with `-showTrends` to open the sheet
|
// QA hook: launch with `-showTrends` to open the sheet
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ struct FuelBoardApp: App {
|
|||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
rootView
|
||||||
.onOpenURL(perform: handleOpenURL)
|
.onOpenURL(perform: handleOpenURL)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchRefreshRequested)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchRefreshRequested)) { _ in
|
||||||
WatchSyncManager.shared.pushSnapshot()
|
WatchSyncManager.shared.pushSnapshot()
|
||||||
@@ -32,6 +32,24 @@ struct FuelBoardApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
#if DEBUG
|
||||||
private func startQALiveActivity(fuel: FuelType) {
|
private func startQALiveActivity(fuel: FuelType) {
|
||||||
let state = FuelBoardLiveActivityAttributes.ContentState(
|
let state = FuelBoardLiveActivityAttributes.ContentState(
|
||||||
@@ -42,6 +60,10 @@ struct FuelBoardApp: App {
|
|||||||
pricePence: 1499,
|
pricePence: 1499,
|
||||||
priceDisplayStyle: FuelStore.loadPriceDisplayStyle(),
|
priceDisplayStyle: FuelStore.loadPriceDisplayStyle(),
|
||||||
distanceKM: 8.0,
|
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,
|
lat: 51.5,
|
||||||
lng: -0.12,
|
lng: -0.12,
|
||||||
updatedAt: Date()
|
updatedAt: Date()
|
||||||
@@ -69,9 +91,40 @@ struct FuelBoardApp: App {
|
|||||||
/// `http://maps.apple.com` form is also handled, from very old timelines.
|
/// `http://maps.apple.com` form is also handled, from very old timelines.
|
||||||
/// In CarPlay the widget uses the same `maps://` URL, which routes to
|
/// In CarPlay the widget uses the same `maps://` URL, which routes to
|
||||||
/// Apple Maps without ever launching this app.
|
/// 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) {
|
private func handleOpenURL(_ url: URL) {
|
||||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
let items = components.queryItems
|
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 }
|
else { return }
|
||||||
|
|
||||||
// Preferred: maps://?daddr=lat,lng&t=d (also covers http://maps.apple.com).
|
// Preferred: maps://?daddr=lat,lng&t=d (also covers http://maps.apple.com).
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FuelSavingsAdjustmentView: View {
|
||||||
|
@Binding var amount: FuelSavingsFillAmount
|
||||||
|
let referencePricePencePerLitre: Double?
|
||||||
|
|
||||||
|
private var range: ClosedRange<Double> {
|
||||||
|
FuelSavingsCalculator.fillAmountSliderRange(for: amount.unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var step: Double {
|
||||||
|
FuelSavingsCalculator.fillAmountSliderStep(for: amount.unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-out of the figure the slider is on. Deliberately NOT a text field:
|
||||||
|
/// a typed figure could sit outside the slider's own bounds, leaving the
|
||||||
|
/// control and the value it claimed to show disagreeing. Slider-only means
|
||||||
|
/// what you see is the figure the estimate actually uses.
|
||||||
|
private var valueText: String {
|
||||||
|
switch amount.unit {
|
||||||
|
case .pounds: return String(format: "£%.0f", amount.value)
|
||||||
|
case .litres: return String(format: "%.0f L", amount.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("How much are you filling up?")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Picker("Fill-up unit", selection: unitBinding) {
|
||||||
|
ForEach(FuelSavingsFillUnit.allCases) { unit in
|
||||||
|
Text(unit.displayName).tag(unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
HStack {
|
||||||
|
Slider(value: sliderBinding, in: range, step: step)
|
||||||
|
.accessibilityLabel("Fill-up amount")
|
||||||
|
.accessibilityValue(valueText)
|
||||||
|
Text(valueText)
|
||||||
|
.monospacedDigit()
|
||||||
|
.frame(width: 72, alignment: .trailing)
|
||||||
|
}
|
||||||
|
Text("Changes the estimate for this session only — your Settings default stays unchanged.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var unitBinding: Binding<FuelSavingsFillUnit> {
|
||||||
|
Binding(
|
||||||
|
get: { amount.unit },
|
||||||
|
set: { newUnit in
|
||||||
|
guard newUnit != amount.unit else { return }
|
||||||
|
if let referencePricePencePerLitre,
|
||||||
|
let converted = try? FuelSavingsCalculator.convert(amount: amount, to: newUnit, referencePricePencePerLitre: referencePricePencePerLitre) {
|
||||||
|
amount = converted
|
||||||
|
} else {
|
||||||
|
amount = FuelSavingsFillAmount(value: newUnit == .pounds ? 40 : 30, unit: newUnit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sliderBinding: Binding<Double> {
|
||||||
|
Binding(
|
||||||
|
get: { min(max(amount.value, range.lowerBound), range.upperBound) },
|
||||||
|
set: { amount.value = min(max($0, range.lowerBound), range.upperBound) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FuelSavingsSummaryView: View {
|
||||||
|
let availability: FuelSavingsAvailability?
|
||||||
|
let amount: FuelSavingsFillAmount
|
||||||
|
let baselineStationName: String?
|
||||||
|
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 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) {
|
||||||
|
capsule(for: result, compact: true)
|
||||||
|
.accessibilityLabel(accessibilityHeadline(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) {
|
||||||
|
capsule(for: result, compact: false)
|
||||||
|
.accessibilityLabel(accessibilityHeadline(for: result))
|
||||||
|
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 with a price for this fuel — every other saving is measured against it.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("Compared with \(baselineStationName ?? "your nearest station") for \(amountText).").font(.caption).foregroundStyle(.secondary)
|
||||||
|
LabeledContent("Fuel-price saving", value: FuelSavingsFormat.money(result.grossSavingGBP))
|
||||||
|
LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
/// estimated comparisons 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 — ONE implementation for the listing rows
|
||||||
|
/// and the detail sheet, so the verdict reads identically in both; the sheet
|
||||||
|
/// just gets the larger font and padding.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
private func capsule(for result: FuelSavingsResult, compact: Bool) -> some View {
|
||||||
|
HStack(spacing: compact ? 3 : 5) {
|
||||||
|
Image(systemName: symbol(for: result.status))
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
Text(result.capsuleHeadline())
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
.font(compact ? .caption.weight(.semibold) : .headline)
|
||||||
|
.foregroundStyle(capsuleColor(for: result.status))
|
||||||
|
.padding(.horizontal, compact ? 8 : 12)
|
||||||
|
.padding(.vertical, compact ? 4 : 7)
|
||||||
|
.background(Capsule().fill(capsuleColor(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.
|
||||||
|
private func capsuleColor(for status: FuelSavingsStatus) -> Color {
|
||||||
|
switch status {
|
||||||
|
case .worthTheDrive: return .green
|
||||||
|
case .notWorthTheDrive: return .red
|
||||||
|
case .breakEven: return .secondary
|
||||||
|
case .baseline: return .blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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,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
|
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(
|
let state = FuelBoardLiveActivityAttributes.ContentState(
|
||||||
fuel: fuel,
|
fuel: fuel,
|
||||||
stationID: best.id,
|
stationID: best.id,
|
||||||
@@ -57,7 +62,8 @@ enum LiveActivityManager {
|
|||||||
brand: best.brand,
|
brand: best.brand,
|
||||||
pricePence: price,
|
pricePence: price,
|
||||||
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
|
priceDisplayStyle: priceDisplayStyle ?? FuelStore.loadPriceDisplayStyle(),
|
||||||
distanceKM: FuelStore.displayDistanceKM(station: best, userLat: location.lat, userLng: location.lng),
|
distanceKM: distance.km,
|
||||||
|
distanceIsApproximate: distance.isApproximate,
|
||||||
lat: best.lat,
|
lat: best.lat,
|
||||||
lng: best.lng,
|
lng: best.lng,
|
||||||
updatedAt: Date()
|
updatedAt: Date()
|
||||||
@@ -119,14 +125,20 @@ enum LiveActivityManager {
|
|||||||
lat: Double,
|
lat: Double,
|
||||||
lng: Double
|
lng: Double
|
||||||
) -> FuelStation? {
|
) -> 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
|
stations
|
||||||
.filter { $0.prices[fuel] != nil }
|
.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
|
.min { lhs, rhs in
|
||||||
let lPrice = lhs.prices[fuel]!
|
let lPrice = lhs.prices[fuel]!
|
||||||
let rPrice = rhs.prices[fuel]!
|
let rPrice = rhs.prices[fuel]!
|
||||||
if lPrice != rPrice { return lPrice < rPrice }
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-50
@@ -3,19 +3,31 @@ import CoreLocation
|
|||||||
import UserNotifications
|
import UserNotifications
|
||||||
import Network
|
import Network
|
||||||
|
|
||||||
/// First-launch onboarding: introduces FuelBoard, then walks the user through
|
/// First-launch onboarding: introduces FuelBoard, walks the user through the
|
||||||
/// the three system permissions (location, notifications, local network for
|
/// two system permissions (location and notifications) with in-context prompts,
|
||||||
/// data loading) with in-context prompts. In production it appears once at
|
/// then offers Best Value — explained, switched on or declined, and configured
|
||||||
/// initial launch (driven by `FuelStore.loadHasCompletedOnboarding()`); a
|
/// with the same sliders Settings uses. Price data downloads over HTTPS with no
|
||||||
/// test button in the Alerts tab re-opens it anytime.
|
/// 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 {
|
struct OnboardingView: View {
|
||||||
var onFinish: () -> Void
|
var onFinish: () -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@StateObject private var prompter = OnboardingPermissionPrompter()
|
@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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -23,7 +35,7 @@ struct OnboardingView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(0..<totalPages, id: \.self) { index in
|
ForEach(0..<Self.totalPages, id: \.self) { index in
|
||||||
Circle()
|
Circle()
|
||||||
.fill(index == page ? Color.accentColor : Color.gray.opacity(0.25))
|
.fill(index == page ? Color.accentColor : Color.gray.opacity(0.25))
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8)
|
||||||
@@ -39,7 +51,8 @@ struct OnboardingView: View {
|
|||||||
locationPage.tag(1)
|
locationPage.tag(1)
|
||||||
notificationsPage.tag(2)
|
notificationsPage.tag(2)
|
||||||
dataPage.tag(3)
|
dataPage.tag(3)
|
||||||
donePage.tag(4)
|
bestValuePage.tag(4)
|
||||||
|
donePage.tag(5)
|
||||||
}
|
}
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
|
|
||||||
@@ -97,23 +110,23 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
.padding(.bottom, 28)
|
.padding(.bottom, 28)
|
||||||
|
|
||||||
Text("Welcome to FuelBoard")
|
Text(String(localized: "Welcome to FuelBoard", comment: "Onboarding welcome title"))
|
||||||
.font(.largeTitle.bold())
|
.font(.largeTitle.bold())
|
||||||
.multilineTextAlignment(.center)
|
.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)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.horizontal, 32)
|
.padding(.horizontal, 32)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
|
||||||
featureRow(icon: "globe.europe.africa.fill", text: "UK-wide prices — 8,000+ stations, updated twice a day")
|
featureRow(icon: "globe.europe.africa.fill", text: String(localized: "UK-wide prices — 8,000+ stations, updated twice a day", comment: "Onboarding feature row"))
|
||||||
featureRow(icon: "scope", text: "Cheapest within your chosen radius, or closest station first")
|
featureRow(icon: "scope", text: String(localized: "Cheapest within your chosen radius, or closest station first", comment: "Onboarding feature row"))
|
||||||
featureRow(icon: "star.fill", text: "Favourites with instant price comparison")
|
featureRow(icon: "star.fill", text: String(localized: "Favourites with instant price comparison", comment: "Onboarding feature row"))
|
||||||
featureRow(icon: "chart.bar.fill", text: "Green/amber/red rating vs the best nearby price")
|
featureRow(icon: "chart.bar.fill", text: String(localized: "Green/amber/red rating vs the best nearby price", comment: "Onboarding feature row"))
|
||||||
featureRow(icon: "square.grid.2x2.fill", text: "Home-screen widget showing the cheapest nearby")
|
featureRow(icon: "square.grid.2x2.fill", text: String(localized: "Home-screen widget showing the cheapest nearby", comment: "Onboarding feature row"))
|
||||||
featureRow(icon: "bell.fill", text: "Alerts when you approach the cheapest station")
|
featureRow(icon: "bell.fill", text: String(localized: "Alerts when you approach the cheapest station", comment: "Onboarding feature row"))
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -129,10 +142,10 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
.padding(.bottom, 28)
|
.padding(.bottom, 28)
|
||||||
|
|
||||||
Text("Prices near you")
|
Text(String(localized: "Prices near you", comment: "Onboarding location permission title"))
|
||||||
.font(.largeTitle.bold())
|
.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)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -142,7 +155,7 @@ struct OnboardingView: View {
|
|||||||
permissionStatusLabel(
|
permissionStatusLabel(
|
||||||
granted: prompter.locationGranted,
|
granted: prompter.locationGranted,
|
||||||
denied: prompter.locationDenied,
|
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 \"nearest\" sorting needs it.", comment: "Onboarding location denied text")
|
||||||
)
|
)
|
||||||
.padding(.top, 24)
|
.padding(.top, 24)
|
||||||
|
|
||||||
@@ -160,10 +173,10 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
.padding(.bottom, 28)
|
.padding(.bottom, 28)
|
||||||
|
|
||||||
Text("Cheapest-station alerts")
|
Text(String(localized: "Cheapest-station alerts", comment: "Onboarding notifications title"))
|
||||||
.font(.largeTitle.bold())
|
.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)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -173,7 +186,7 @@ struct OnboardingView: View {
|
|||||||
permissionStatusLabel(
|
permissionStatusLabel(
|
||||||
granted: prompter.notificationsGranted,
|
granted: prompter.notificationsGranted,
|
||||||
denied: prompter.notificationsDenied,
|
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)
|
.padding(.top, 24)
|
||||||
|
|
||||||
@@ -191,10 +204,10 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
.padding(.bottom, 28)
|
.padding(.bottom, 28)
|
||||||
|
|
||||||
Text("Prices, ready when you are")
|
Text(String(localized: "Prices, ready when you are", comment: "Onboarding data title"))
|
||||||
.font(.largeTitle.bold())
|
.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)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -205,6 +218,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 {
|
private var donePage: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -218,10 +237,10 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
.padding(.bottom, 28)
|
.padding(.bottom, 28)
|
||||||
|
|
||||||
Text("You're all set")
|
Text(String(localized: "You're all set", comment: "Onboarding final title"))
|
||||||
.font(.largeTitle.bold())
|
.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)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -238,11 +257,9 @@ struct OnboardingView: View {
|
|||||||
private var bottomAction: some View {
|
private var bottomAction: some View {
|
||||||
switch page {
|
switch page {
|
||||||
case 0:
|
case 0:
|
||||||
primaryButton("Continue") { page = 1 }
|
primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) { page = 1 }
|
||||||
case 1:
|
case 1:
|
||||||
primaryButton(
|
primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) {
|
||||||
prompter.locationDenied ? "Open Settings" : (prompter.locationGranted ? "Continue" : "Allow Location Access")
|
|
||||||
) {
|
|
||||||
if prompter.locationDenied {
|
if prompter.locationDenied {
|
||||||
openSettings()
|
openSettings()
|
||||||
} else if prompter.locationGranted {
|
} else if prompter.locationGranted {
|
||||||
@@ -252,14 +269,12 @@ struct OnboardingView: View {
|
|||||||
// screen (after it's been read) and BEFORE advancing — so
|
// screen (after it's been read) and BEFORE advancing — so
|
||||||
// the prompt never covers the next page's animation. Auto-
|
// the prompt never covers the next page's animation. Auto-
|
||||||
// advance on grant moves us on once the user responds;
|
// advance on grant moves us on once the user responds;
|
||||||
// denying leaves the page's "Open Settings" path.
|
// denying leaves the page's Settings path.
|
||||||
prompter.requestLocation()
|
prompter.requestLocation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 2:
|
case 2:
|
||||||
primaryButton(
|
primaryButton(String(localized: "Continue", comment: "Onboarding continue button")) {
|
||||||
prompter.notificationsDenied ? "Continue without alerts" : (prompter.notificationsGranted ? "Continue" : "Allow Notifications")
|
|
||||||
) {
|
|
||||||
if prompter.notificationsDenied || prompter.notificationsGranted {
|
if prompter.notificationsDenied || prompter.notificationsGranted {
|
||||||
page = 3 // decided either way — move on
|
page = 3 // decided either way — move on
|
||||||
} else {
|
} else {
|
||||||
@@ -269,23 +284,21 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
case 3:
|
case 3:
|
||||||
// No permission on this page — prices need none. Straight on.
|
// 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:
|
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 {
|
private func primaryButton(_ title: String, action: @escaping () -> Void) -> some View {
|
||||||
Button(action: action) {
|
OnboardingPrimaryButton(title: title, action: action)
|
||||||
Text(title)
|
|
||||||
.font(.headline)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func featureRow(icon: String, text: LocalizedStringKey) -> some View {
|
private func featureRow(icon: String, text: String) -> some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 20, weight: .semibold))
|
.font(.system(size: 20, weight: .semibold))
|
||||||
@@ -299,10 +312,10 @@ struct OnboardingView: View {
|
|||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: LocalizedStringKey) -> some View {
|
private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: String) -> some View {
|
||||||
Group {
|
Group {
|
||||||
if granted {
|
if granted {
|
||||||
Label("Allowed", systemImage: "checkmark.circle.fill")
|
Label(String(localized: "Allowed", comment: "Permission granted status"), systemImage: "checkmark.circle.fill")
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
} else if denied {
|
} else if denied {
|
||||||
Text(deniedText)
|
Text(deniedText)
|
||||||
@@ -311,7 +324,7 @@ struct OnboardingView: View {
|
|||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.horizontal, 32)
|
.padding(.horizontal, 32)
|
||||||
} else {
|
} else {
|
||||||
Text("The system prompt will appear next.")
|
Text(String(localized: "The system prompt will appear next.", comment: "Permission prompt pending status"))
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -326,12 +339,51 @@ struct OnboardingView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func finish() {
|
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)
|
FuelStore.saveHasCompletedOnboarding(true)
|
||||||
onFinish()
|
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 struct AnimatedOnboardingIcon<Content: View>: View {
|
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 {
|
enum Motion {
|
||||||
case gentle
|
case gentle
|
||||||
case locationArrow
|
case locationArrow
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
@Published var lastAlert: String?
|
@Published var lastAlert: String?
|
||||||
@Published private(set) var lastTestResult: String?
|
@Published private(set) var lastTestResult: String?
|
||||||
@Published var pendingStationMap: StationMapRequest?
|
@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
|
/// Debug-only snapshot for the Settings → Debug section. Recomputed on
|
||||||
/// every location/stations change via `refreshDebugStatus()`.
|
/// every location/stations change via `refreshDebugStatus()`.
|
||||||
@Published private(set) var debugStatus: DebugLocationStatus?
|
@Published private(set) var debugStatus: DebugLocationStatus?
|
||||||
@@ -108,6 +111,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
private var favourites: [FuelStation] = []
|
private var favourites: [FuelStation] = []
|
||||||
private var fuel: FuelType = .e10
|
private var fuel: FuelType = .e10
|
||||||
private var radiusKM: Double = 3.0
|
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 lastNotified: [String: Date] = [:] // dedup per station
|
||||||
private var enabled = false
|
private var enabled = false
|
||||||
/// Dynamically registered categories for tie-choice notifications. Kept so
|
/// Dynamically registered categories for tie-choice notifications. Kept so
|
||||||
@@ -132,6 +139,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
radiusKM = FuelStore.effectiveAlertsRadiusKM(
|
radiusKM = FuelStore.effectiveAlertsRadiusKM(
|
||||||
followsSearch: FuelStore.loadAlertsFollowsSearch(),
|
followsSearch: FuelStore.loadAlertsFollowsSearch(),
|
||||||
manualKM: FuelStore.loadAlertsRadius())
|
manualKM: FuelStore.loadAlertsRadius())
|
||||||
|
alertMode = FuelStore.loadAlertsMode()
|
||||||
|
bestValueThresholdGBP = FuelStore.loadAlertsBestValueThresholdGBP()
|
||||||
|
fuelSavingsSettings = FuelStore.loadFuelSavingsSettings()
|
||||||
|
fuelSavingsAmount = fuelSavingsSettings.preferredAmount
|
||||||
stations = FuelStore.loadStations()
|
stations = FuelStore.loadStations()
|
||||||
favourites = FuelStore.loadFavourites().filter { $0.fuel == fuel }.map(\.station)
|
favourites = FuelStore.loadFavourites().filter { $0.fuel == fuel }.map(\.station)
|
||||||
}
|
}
|
||||||
@@ -140,7 +151,16 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
/// Favourites are fuel-scoped entries; only entries for the monitored fuel
|
/// Favourites are fuel-scoped entries; only entries for the monitored fuel
|
||||||
/// get priority slots (a Diesel favourite must not grab a slot while
|
/// get priority slots (a Diesel favourite must not grab a slot while
|
||||||
/// monitoring Unleaded).
|
/// 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
|
// Fall back to the shared cache when called before the first fetch
|
||||||
// completes (launch, background region-event wake).
|
// completes (launch, background region-event wake).
|
||||||
self.stations = stations.isEmpty ? FuelStore.loadStations() : stations
|
self.stations = stations.isEmpty ? FuelStore.loadStations() : stations
|
||||||
@@ -149,6 +169,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
: favourites.filter { $0.fuel == fuel }.map(\.station)
|
: favourites.filter { $0.fuel == fuel }.map(\.station)
|
||||||
self.fuel = fuel
|
self.fuel = fuel
|
||||||
self.radiusKM = radiusKM
|
self.radiusKM = radiusKM
|
||||||
|
self.alertMode = alertMode
|
||||||
|
self.bestValueThresholdGBP = bestValueThresholdGBP
|
||||||
|
self.fuelSavingsSettings = fuelSavingsSettings
|
||||||
|
self.fuelSavingsAmount = fuelSavingsAmount
|
||||||
|
|
||||||
refreshDebugStatus()
|
refreshDebugStatus()
|
||||||
|
|
||||||
@@ -166,17 +190,36 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
// Debug section only shows the CURRENT region problem.
|
// Debug section only shows the CURRENT region problem.
|
||||||
lastRegionError = nil
|
lastRegionError = nil
|
||||||
|
|
||||||
// Favourites first (guaranteed slots), then closest stations, max 18.
|
// Favourites first (guaranteed slots). For Best Value mode, spend the
|
||||||
// `self.favourites` is already filtered to the monitored fuel.
|
// 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
|
var candidates: [FuelStation] = self.favourites
|
||||||
let favIDs = Set(self.favourites.map(\.id))
|
let favIDs = Set(self.favourites.map(\.id))
|
||||||
let location = FuelStore.loadLocation()
|
let location = FuelStore.loadLocation()
|
||||||
let others = stations
|
let eligibleOthers = stations.filter { !favIDs.contains($0.id) && $0.prices[fuel] != nil }
|
||||||
.filter { !favIDs.contains($0.id) }
|
let others: [FuelStation]
|
||||||
.sorted { lhs, rhs in
|
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 }
|
guard let location else { return false }
|
||||||
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
|
return FuelStore.displayDistanceKM(station: lhs, userLat: location.lat, userLng: location.lng) <
|
||||||
rhs.distanceKM(to: location.lat, lng2: location.lng)
|
FuelStore.displayDistanceKM(station: rhs, userLat: location.lat, userLng: location.lng)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
candidates.append(contentsOf: others)
|
candidates.append(contentsOf: others)
|
||||||
|
|
||||||
@@ -219,6 +262,33 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
|
|
||||||
var isEnabled: Bool { enabled }
|
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-
|
/// Compares the latest refreshed favourites against the persisted cheapest-
|
||||||
/// favourite baseline for one fuel and schedules a notification when the
|
/// favourite baseline for one fuel and schedules a notification when the
|
||||||
/// winner changes or the current winner drops by a meaningful amount.
|
/// winner changes or the current winner drops by a meaningful amount.
|
||||||
@@ -313,12 +383,15 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let sellers = stations.filter { $0.prices[fuel] != nil }
|
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 {
|
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 cheapest = inRange.min { ($0.prices[fuel] ?? .infinity) < ($1.prices[fuel] ?? .infinity) }
|
||||||
let cheapestDistance = cheapest.map {
|
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(
|
debugStatus = DebugLocationStatus(
|
||||||
coordinate: fix.coordinate,
|
coordinate: fix.coordinate,
|
||||||
@@ -432,6 +505,22 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
$0.prices[fuel] != nil &&
|
$0.prices[fuel] != nil &&
|
||||||
$0.distanceKM(to: station.lat, lng2: station.lng) <= radiusKM
|
$0.distanceKM(to: station.lat, lng2: station.lng) <= radiusKM
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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".
|
||||||
|
logAlert(.error, "\(station.name) — fresh-price fetch failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleCheapestEntry(station: FuelStation, withinRadius: [FuelStation]) {
|
||||||
guard let cheapest = withinRadius.min(by: { $0.prices[fuel]! < $1.prices[fuel]! }) else {
|
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")
|
logAlert(.gate, "\(station.name) — no \(fuel.displayName.lowercased()) sellers within \(String(format: "%.1f", radiusKM)) km after fresh fetch")
|
||||||
return
|
return
|
||||||
@@ -450,13 +539,44 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
)
|
)
|
||||||
|
|
||||||
fireAlert(for: cheapest, price: price, ties: tied)
|
fireAlert(for: cheapest, price: price, ties: tied)
|
||||||
lastNotified[stationID] = Date()
|
lastNotified[station.id] = Date()
|
||||||
} catch {
|
|
||||||
// Trace the silent catch — a relay failure drops the alert
|
|
||||||
// with no banner, which previously looked like "no alert".
|
|
||||||
logAlert(.error, "\(station.name) — fresh-price fetch failed: \(error.localizedDescription)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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] = []) {
|
private func fireAlert(for station: FuelStation, price: Double, ties: [FuelStation] = []) {
|
||||||
@@ -493,6 +613,17 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
lastAlert = "\(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away"
|
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) could leave you \(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(
|
private func fireFavouriteDropAlert(
|
||||||
current: FavouriteEntry,
|
current: FavouriteEntry,
|
||||||
previous: FavouriteAlertSnapshot,
|
previous: FavouriteAlertSnapshot,
|
||||||
@@ -663,6 +794,15 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
/// the geofence itself. Uses the monitor's live state (fuel, radius,
|
/// the geofence itself. Uses the monitor's live state (fuel, radius,
|
||||||
/// stations) so the notification reflects the Alerts-tab configuration.
|
/// stations) so the notification reflects the Alerts-tab configuration.
|
||||||
func sendTestNotification() {
|
func sendTestNotification() {
|
||||||
|
switch alertMode {
|
||||||
|
case .cheapest:
|
||||||
|
sendCheapestTestNotification()
|
||||||
|
case .bestValue:
|
||||||
|
sendBestValueTestNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendCheapestTestNotification() {
|
||||||
let unit = FuelStore.loadDistanceUnit()
|
let unit = FuelStore.loadDistanceUnit()
|
||||||
let location = FuelStore.loadLocation()
|
let location = FuelStore.loadLocation()
|
||||||
let sellers = stations.filter { $0.prices[fuel] != nil }
|
let sellers = stations.filter { $0.prices[fuel] != nil }
|
||||||
@@ -671,7 +811,8 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
// identical to handleEntry's withinRadius logic.
|
// identical to handleEntry's withinRadius logic.
|
||||||
let inRadius = sellers.filter { station in
|
let inRadius = sellers.filter { station in
|
||||||
guard let location else { return true }
|
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) }
|
let candidate = inRadius.min { ($0.prices[fuel] ?? .infinity) < ($1.prices[fuel] ?? .infinity) }
|
||||||
|
|
||||||
@@ -738,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) could leave you \(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
|
/// Fires a notification with NO criteria checks at all — no geofence, no
|
||||||
/// cheapest-within-radius, no dedup, no permission gate, no fuel/radius
|
/// cheapest-within-radius, no dedup, no permission gate, no fuel/radius
|
||||||
/// coupling. Still carries a REAL station (the nearest one selling the
|
/// coupling. Still carries a REAL station (the nearest one selling the
|
||||||
@@ -791,9 +996,11 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tapping a notification opens Apple Maps directions to the station from
|
/// Tapping a notification honours Settings → Tap station: Open map opens
|
||||||
/// the user's current location. If Apple Maps can't be opened (e.g. inside
|
/// Apple Maps directions to the station from the user's current location;
|
||||||
/// LiveContainer), an in-app map sheet with directions is shown instead.
|
/// 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
|
/// When the notification carried tie-choice action buttons, the tapped
|
||||||
/// button's identifier ("tie_0", "tie_1", …) selects the corresponding
|
/// button's identifier ("tie_0", "tie_1", …) selects the corresponding
|
||||||
@@ -818,9 +1025,9 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
|
|||||||
let tieName = ties[index]["name"] as? String,
|
let tieName = ties[index]["name"] as? String,
|
||||||
let tieLat = ties[index]["lat"] as? Double,
|
let tieLat = ties[index]["lat"] as? Double,
|
||||||
let tieLng = ties[index]["lng"] 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 {
|
} 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
|
// Tie body tap: intentionally no directions — the user must pick
|
||||||
// an option button to choose a station.
|
// an option button to choose a station.
|
||||||
@@ -828,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
|
/// Opens Apple Maps with driving directions to the station; falls back to
|
||||||
/// the in-app map sheet when the hand-off fails.
|
/// the in-app map sheet when the hand-off fails.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -7,67 +7,401 @@
|
|||||||
// here, not in the widget. The widget/Live Activity just read the cache.
|
// here, not in the widget. The widget/Live Activity just read the cache.
|
||||||
//
|
//
|
||||||
// Throttling: recompute at most every `throttleMinutes`, or when the user has
|
// Throttling: recompute at most every `throttleMinutes`, or when the user has
|
||||||
// moved `moveThresholdMeters` from where the cache was built. Bounded to the
|
// moved `moveThresholdMeters` from where the cache was built. In
|
||||||
// `candidatesPerPass` nearest stations so a pass stays a handful of route calls.
|
// "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 Foundation
|
||||||
import MapKit
|
import MapKit
|
||||||
|
import UIKit
|
||||||
import WidgetKit
|
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 {
|
enum RoadDistanceService {
|
||||||
/// Upper bound on stations routed per pass, so a pass stays a bounded set of
|
/// Upper bound on stations routed per pass. Accurate-distances mode routes the
|
||||||
/// route calls. Raised from 12 so stations past the old nearest-12 cutoff
|
/// whole search area in ONE pass, because a 12-per-pass budget could never
|
||||||
/// still get real road distances instead of a straight-line fallback.
|
/// make a list fully routed — that is exactly why two devices (and two
|
||||||
static let candidatesPerPass = 40
|
/// 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
|
/// 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
|
/// 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.
|
/// every station a widget/Live Activity/list can actually show gets routed.
|
||||||
static let maxRadiusKM: Double = 25
|
static let maxRadiusKM: Double = 25
|
||||||
/// Don't route again more often than this (minutes).
|
/// Don't route again more often than this (minutes) once the set is whole.
|
||||||
static let throttleMinutes: Double = 10
|
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
|
/// Recompute when the user moves more than this (metres) from the last
|
||||||
/// source location.
|
/// source location.
|
||||||
static let moveThresholdMeters: Double = 400
|
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
|
/// Refreshes the cached road distances for the in-radius stations around
|
||||||
/// `lat`/`lng`. Throttled by time + distance; safe to call on every fix.
|
/// `lat`/`lng`. Throttled by time + distance; safe to call on every fix.
|
||||||
static func refreshIfNeeded(stations: [FuelStation], lat: Double, lng: Double) async {
|
///
|
||||||
guard !stations.isEmpty else { return }
|
/// - 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.
|
// Throttle: keep cached values when fresh and the user hasn't moved far.
|
||||||
if let cache = FuelStore.loadRoadDistances() {
|
// `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 elapsed = Date().timeIntervalSince1970 - cache.updatedAt
|
||||||
let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng)
|
let movedMeters = haversineMeters(cache.sourceLat, cache.sourceLng, lat, lng)
|
||||||
if elapsed < throttleMinutes * 60 && movedMeters < moveThresholdMeters {
|
// 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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Candidate stations: the nearest-by-straight-line subset that the UI
|
// Candidates: Worth-the-Drive priorities first, then the nearest
|
||||||
// could actually display, capped so a pass stays bounded.
|
// stations. Stations that already have a route from this origin are
|
||||||
let nearest = stations
|
// skipped so the per-pass budget goes on the ones still missing one.
|
||||||
.sorted { $0.distanceKM(to: lat, lng2: lng) < $1.distanceKM(to: lat, lng2: lng) }
|
var candidates: [FuelStation] = []
|
||||||
.prefix(candidatesPerPass)
|
var seen = Set<String>()
|
||||||
.filter { $0.distanceKM(to: lat, lng2: lng) <= maxRadiusKM }
|
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)
|
let origin = CLLocationCoordinate2D(latitude: lat, longitude: lng)
|
||||||
var entries: [String: CachedRoadDistance] = [:]
|
var entries: [String: CachedRoadDistance] = originCompatible ? (existing?.entries ?? [:]) : [:]
|
||||||
for station in nearest {
|
|
||||||
|
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)
|
let dest = CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
|
||||||
if let meters = await roadMeters(from: origin, to: dest) {
|
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
|
// Store the exact pin that was routed so the display layer can
|
||||||
// refuse to serve this value if the station later appears with
|
// refuse to serve this value if the station later appears with
|
||||||
// a different coordinate (corrected pin / other data source).
|
// a different coordinate (corrected pin / other data source).
|
||||||
|
let returnMeters = await back
|
||||||
entries[station.id] = CachedRoadDistance(meters: meters,
|
entries[station.id] = CachedRoadDistance(meters: meters,
|
||||||
lat: station.lat,
|
lat: station.lat,
|
||||||
lng: station.lng)
|
lng: station.lng,
|
||||||
|
returnMeters: returnMeters)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard !entries.isEmpty else { return }
|
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
|
||||||
|
}
|
||||||
|
|
||||||
FuelStore.saveRoadDistances(sourceLat: lat, sourceLng: lng, entries: entries)
|
// A pass is complete when nothing in the search area is left UNRESOLVED:
|
||||||
// Wake the widgets so the new road distances surface immediately.
|
// 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()
|
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.
|
/// Driving distance (metres) between two coordinates via Apple Maps routing.
|
||||||
@@ -96,3 +430,17 @@ enum RoadDistanceService {
|
|||||||
return r * 2 * atan2(sqrt(a), sqrt(1 - a))
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+366
-24
@@ -18,6 +18,8 @@ struct SettingsView: View {
|
|||||||
@ObservedObject var tipStore: TipStore
|
@ObservedObject var tipStore: TipStore
|
||||||
@Binding var distanceUnit: DistanceUnit
|
@Binding var distanceUnit: DistanceUnit
|
||||||
@Binding var priceDisplayStyle: PriceDisplayStyle
|
@Binding var priceDisplayStyle: PriceDisplayStyle
|
||||||
|
@Binding var stationTapAction: StationTapAction
|
||||||
|
@Binding var fuelSavingsSettings: FuelSavingsSettings
|
||||||
@Binding var dataRefreshMode: DataRefreshMode
|
@Binding var dataRefreshMode: DataRefreshMode
|
||||||
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
||||||
/// tab) so the test notification matches what real alerts will say.
|
/// tab) so the test notification matches what real alerts will say.
|
||||||
@@ -52,6 +54,7 @@ struct SettingsView: View {
|
|||||||
var regionEventCount: Int = 0
|
var regionEventCount: Int = 0
|
||||||
/// Debug fence state (monitor.debugFenceIdentifier) — armed vs cleared.
|
/// Debug fence state (monitor.debugFenceIdentifier) — armed vs cleared.
|
||||||
var debugFenceIdentifier: String?
|
var debugFenceIdentifier: String?
|
||||||
|
var roadDistanceDebugStatus: RoadDistanceFillDebugStatus = .idle
|
||||||
/// Arms a 100 m fence at the current location (monitor.registerDebugFenceAroundMe).
|
/// Arms a 100 m fence at the current location (monitor.registerDebugFenceAroundMe).
|
||||||
var onDebugFence: () -> Void = {}
|
var onDebugFence: () -> Void = {}
|
||||||
/// Clears the armed fence (monitor.clearDebugFence).
|
/// Clears the armed fence (monitor.clearDebugFence).
|
||||||
@@ -61,9 +64,9 @@ struct SettingsView: View {
|
|||||||
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
||||||
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
||||||
@State private var debugMode: Bool = FuelStore.loadDebugMode()
|
@State private var debugMode: Bool = FuelStore.loadDebugMode()
|
||||||
/// Dev-only: re-inserts the LAN relay into the live chain (off by default —
|
/// "Accurate distances" — read once on appear and written straight through to
|
||||||
/// consumers must never attempt local-network access).
|
/// the shared store, which is what every display surface actually consults.
|
||||||
@State private var relayFallbackEnabled: Bool = FuelStore.loadRelayFallbackEnabled()
|
@State private var accurateDistances: Bool = FuelStore.accurateDistancesEnabled
|
||||||
@State private var versionTapCount = 0
|
@State private var versionTapCount = 0
|
||||||
@State private var lastVersionTap = Date.distantPast
|
@State private var lastVersionTap = Date.distantPast
|
||||||
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
|
/// Widget diagnostics: the extension's last makeEntry beacon PER intent
|
||||||
@@ -72,6 +75,17 @@ struct SettingsView: View {
|
|||||||
@State private var mediumWidgetDiag: String?
|
@State private var mediumWidgetDiag: String?
|
||||||
@State private var installedWidgets: 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.
|
||||||
|
@FocusState private var focusedSavingsField: SavingsField?
|
||||||
|
|
||||||
|
private enum SavingsField: Hashable {
|
||||||
|
case averageMPG
|
||||||
|
case fillAmount
|
||||||
|
}
|
||||||
|
|
||||||
private func refreshWidgetDiag() {
|
private func refreshWidgetDiag() {
|
||||||
smallWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardSmallWidget")
|
smallWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardSmallWidget")
|
||||||
mediumWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardWidgetConfigurationIntent")
|
mediumWidgetDiag = FuelStore.loadWidgetDiag(intentType: "FuelBoardWidgetConfigurationIntent")
|
||||||
@@ -94,6 +108,59 @@ struct SettingsView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
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)
|
||||||
|
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 {
|
Section {
|
||||||
Picker("Distance", selection: $distanceUnit) {
|
Picker("Distance", selection: $distanceUnit) {
|
||||||
ForEach(DistanceUnit.allCases) { unit in
|
ForEach(DistanceUnit.allCases) { unit in
|
||||||
@@ -123,6 +190,36 @@ 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).")
|
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 {
|
Section {
|
||||||
Picker("Data checking", selection: $dataRefreshMode) {
|
Picker("Data checking", selection: $dataRefreshMode) {
|
||||||
ForEach(DataRefreshMode.allCases) { mode in
|
ForEach(DataRefreshMode.allCases) { mode in
|
||||||
@@ -159,10 +256,6 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
if debugMode {
|
if debugMode {
|
||||||
Section {
|
Section {
|
||||||
Toggle("LAN relay fallback (dev)", isOn: $relayFallbackEnabled)
|
|
||||||
.onChange(of: relayFallbackEnabled) { _, enabled in
|
|
||||||
FuelStore.saveRelayFallbackEnabled(enabled)
|
|
||||||
}
|
|
||||||
Button {
|
Button {
|
||||||
sendTestAlert()
|
sendTestAlert()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -232,6 +325,55 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.onAppear { refreshWidgetDiag() }
|
.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 {
|
Section {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Connection")
|
Text("Connection")
|
||||||
@@ -259,6 +401,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.")
|
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 {
|
Section {
|
||||||
if monitoredCount == 0 {
|
if monitoredCount == 0 {
|
||||||
Text("No stations monitored yet — open the Stations tab to load prices first.")
|
Text("No stations monitored yet — open the Stations tab to load prices first.")
|
||||||
@@ -387,6 +559,9 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
// Numeric entry must be escapable: `.decimalPad` has no Return key.
|
||||||
|
.keyboardDoneButton { focusedSavingsField = nil }
|
||||||
|
.scrollDismissesKeyboard(.interactively)
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Task { await tipStore.load() }
|
Task { await tipStore.load() }
|
||||||
@@ -506,6 +681,62 @@ struct SettingsView: View {
|
|||||||
return "\(version) (\(build))"
|
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
|
||||||
|
fuelSavingsSettings.preferredAmount.unit = newUnit
|
||||||
|
if !fuelSavingsSettings.preferredAmount.isValid {
|
||||||
|
fuelSavingsSettings.preferredAmount.value = newUnit == .pounds ? 40 : 30
|
||||||
|
}
|
||||||
|
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"
|
/// "API" when the relay is serving the official Fuel Finder API, "CSV"
|
||||||
/// when it fell back to the public mirror, "—" when unknown/never fetched.
|
/// when it fell back to the public mirror, "—" when unknown/never fetched.
|
||||||
private var connectionText: String {
|
private var connectionText: String {
|
||||||
@@ -614,19 +845,18 @@ final class TipStore: ObservableObject {
|
|||||||
let id: String // product ID
|
let id: String // product ID
|
||||||
let name: String
|
let name: String
|
||||||
let blurb: String
|
let blurb: String
|
||||||
let fallbackPrice: String
|
|
||||||
let accent: Color // card/price accent (matches the fuel palette)
|
let accent: Color // card/price accent (matches the fuel palette)
|
||||||
}
|
}
|
||||||
|
|
||||||
static let tiers: [TipTier] = [
|
static let tiers: [TipTier] = [
|
||||||
TipTier(id: "com.apt.fuelboard.tip099", name: "Splash & Dash",
|
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
|
accent: Color(red: 0.39, green: 0.82, blue: 1.0)), // #64D2FF
|
||||||
TipTier(id: "com.apt.fuelboard.tip299", name: "Half a Tank",
|
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
|
accent: Color(red: 0.19, green: 0.82, blue: 0.35)), // #30D158
|
||||||
TipTier(id: "com.apt.fuelboard.tip499", name: "Fill 'Er Up",
|
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
|
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -640,6 +870,22 @@ final class TipStore: ObservableObject {
|
|||||||
@Published private(set) var products: [String: Product] = [:]
|
@Published private(set) var products: [String: Product] = [:]
|
||||||
@Published private(set) var purchaseInProgress = false
|
@Published private(set) var purchaseInProgress = false
|
||||||
@Published private(set) var outcome: TipOutcome?
|
@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).
|
/// Auto-dismiss timer for the outcome banner (replaced on each new outcome).
|
||||||
private var dismissTask: Task<Void, Never>?
|
private var dismissTask: Task<Void, Never>?
|
||||||
@@ -650,9 +896,31 @@ final class TipStore: ObservableObject {
|
|||||||
/// the queue but are never finished or acknowledged.
|
/// the queue but are never finished or acknowledged.
|
||||||
private var updatesTask: Task<Void, Never>?
|
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() {
|
init() {
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] TipStore initialized; expected IDs=\(Self.tiers.map(\.id).joined(separator: ","))")
|
||||||
updatesTask = Task { [weak self] in
|
updatesTask = Task { [weak self] in
|
||||||
for await update in Transaction.updates {
|
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)
|
await self?.handle(update)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -660,14 +928,27 @@ final class TipStore: ObservableObject {
|
|||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
updatesTask?.cancel()
|
updatesTask?.cancel()
|
||||||
|
unfinishedTask?.cancel()
|
||||||
dismissTask?.cancel()
|
dismissTask?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
|
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Handling transaction update")
|
||||||
// Never deliver or finish an untrusted purchase.
|
// 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).
|
// 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()
|
await transaction.finish()
|
||||||
showOutcome(
|
showOutcome(
|
||||||
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
|
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
|
||||||
@@ -696,45 +977,101 @@ final class TipStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func displayPrice(for tier: TipTier) -> String {
|
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 {
|
func load() async {
|
||||||
// Refreshes product state on every visit so newly-approved products
|
// Refreshes product state on every visit so newly-approved products
|
||||||
// (or restored transactions) are picked up.
|
// (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 {
|
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) })
|
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 {
|
} catch {
|
||||||
// No products yet (sideloaded build) — the buttons still show the
|
lastProductLoadDate = Date()
|
||||||
// intended prices and report purchase attempts gracefully.
|
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 = [:]
|
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 {
|
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
|
purchaseInProgress = true
|
||||||
defer { purchaseInProgress = false }
|
defer { purchaseInProgress = false }
|
||||||
|
|
||||||
// If the product hasn't loaded (e.g. not configured in App Store
|
do {
|
||||||
// Connect yet), still allow the attempt so the user sees a clear
|
guard let product = try await product(for: tier) else {
|
||||||
// outcome rather than a dead button.
|
Self.debugLog("[FuelBoard StoreKit] Purchase stopped: no StoreKit product for ID=\(tier.id)")
|
||||||
guard let product = products[tier.id] else {
|
|
||||||
showOutcome(
|
showOutcome(
|
||||||
NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: ""),
|
NSLocalizedString("This tip isn't available from the App Store yet. Please try again shortly.", comment: ""),
|
||||||
icon: "info.circle.fill",
|
icon: "info.circle.fill",
|
||||||
tint: Color(.secondaryLabel)
|
tint: Color(.secondaryLabel)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
Self.debugLog("[FuelBoard StoreKit] Calling product.purchase() ID=\(product.id) displayPrice=\(product.displayPrice)")
|
||||||
let result = try await product.purchase()
|
let result = try await product.purchase()
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let verification):
|
case .success(let verification):
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Purchase result=success")
|
||||||
switch verification {
|
switch verification {
|
||||||
case .verified(let transaction):
|
case .verified(let transaction):
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Purchase verified productID=\(transaction.productID); finishing")
|
||||||
// Consume the consumable — otherwise StoreKit re-delivers
|
// Consume the consumable — otherwise StoreKit re-delivers
|
||||||
// it through Transaction.updates on every launch.
|
// it through Transaction.updates on every launch.
|
||||||
await transaction.finish()
|
await transaction.finish()
|
||||||
@@ -744,6 +1081,7 @@ final class TipStore: ObservableObject {
|
|||||||
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
|
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
|
||||||
)
|
)
|
||||||
case .unverified:
|
case .unverified:
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Purchase result=unverified")
|
||||||
showOutcome(
|
showOutcome(
|
||||||
NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""),
|
NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""),
|
||||||
icon: "exclamationmark.triangle.fill",
|
icon: "exclamationmark.triangle.fill",
|
||||||
@@ -751,17 +1089,21 @@ final class TipStore: ObservableObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
case .userCancelled:
|
case .userCancelled:
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Purchase result=userCancelled")
|
||||||
dismissOutcome() // silent — the user just closed the sheet
|
dismissOutcome() // silent — the user just closed the sheet
|
||||||
case .pending:
|
case .pending:
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Purchase result=pending")
|
||||||
showOutcome(
|
showOutcome(
|
||||||
NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: ""),
|
NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: ""),
|
||||||
icon: "hourglass",
|
icon: "hourglass",
|
||||||
tint: Color(red: 0.39, green: 0.82, blue: 1.0) // #64D2FF
|
tint: Color(red: 0.39, green: 0.82, blue: 1.0) // #64D2FF
|
||||||
)
|
)
|
||||||
@unknown default:
|
@unknown default:
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Purchase result=unknown")
|
||||||
dismissOutcome()
|
dismissOutcome()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
Self.debugLog("[FuelBoard StoreKit] Purchase threw type=\(String(describing: type(of: error))) error=\(error.localizedDescription)")
|
||||||
showOutcome(
|
showOutcome(
|
||||||
String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription),
|
String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription),
|
||||||
icon: "xmark.circle.fill",
|
icon: "xmark.circle.fill",
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
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
|
||||||
|
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 "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>?
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 "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("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()
|
||||||
|
}
|
||||||
|
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
|
||||||
|
)
|
||||||
|
} 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.")
|
||||||
|
}
|
||||||
|
.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 "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
|
||||||
|
// "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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,12 @@ struct StationsView: View {
|
|||||||
let topStationID: String?
|
let topStationID: String?
|
||||||
let location: Coordinate?
|
let location: Coordinate?
|
||||||
let favouriteIDs: Set<String>
|
let favouriteIDs: Set<String>
|
||||||
|
let stationTapAction: StationTapAction
|
||||||
|
let fuelSavingsSettings: FuelSavingsSettings
|
||||||
|
@Binding var fuelSavingsAmount: FuelSavingsFillAmount
|
||||||
|
var savingsForStation: (FuelStation) -> FuelSavingsAvailability? = { _ in nil }
|
||||||
|
var savingsBaselineStationName: String?
|
||||||
|
var journeyDiagnosticsForStation: (FuelStation) -> FuelSavingsJourneyDiagnostics? = { _ in nil }
|
||||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||||
var onRefresh: () async -> Void = {}
|
var onRefresh: () async -> Void = {}
|
||||||
|
|
||||||
@@ -29,6 +35,9 @@ struct StationsView: View {
|
|||||||
/// info button in the header — keeps the list focused on stations.
|
/// info button in the header — keeps the list focused on stations.
|
||||||
@State private var showKey = false
|
@State private var showKey = false
|
||||||
|
|
||||||
|
/// Station picked for the More-info sheet (tap action = showDetails).
|
||||||
|
@State private var detailStation: FuelStation?
|
||||||
|
|
||||||
/// Bottom-of-tab explainer (moved from the top 2026-08-16): mode/radius/
|
/// Bottom-of-tab explainer (moved from the top 2026-08-16): mode/radius/
|
||||||
/// directions context + "N/TOTAL stations updated". The numerator is the
|
/// directions context + "N/TOTAL stations updated". The numerator is the
|
||||||
/// current list pool; the denominator is the full UK station total from
|
/// current list pool; the denominator is the full UK station total from
|
||||||
@@ -37,7 +46,15 @@ struct StationsView: View {
|
|||||||
let miles = distanceUnit.displayMiles(stationLimit)
|
let miles = distanceUnit.displayMiles(stationLimit)
|
||||||
let unit = distanceUnit.label(for: Double(miles))
|
let unit = distanceUnit.label(for: Double(miles))
|
||||||
let fuel = selectedFuel.displayName
|
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
|
let ratio: String
|
||||||
if let total = FuelStore.loadStationCount() {
|
if let total = FuelStore.loadStationCount() {
|
||||||
ratio = "\(totalCount)/\(total)"
|
ratio = "\(totalCount)/\(total)"
|
||||||
@@ -48,9 +65,9 @@ struct StationsView: View {
|
|||||||
if sortMode == .closest {
|
if sortMode == .closest {
|
||||||
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
return "\(mode) \(fuel) stations — nearest first, best value within \(miles) \(unit) · \(ratio) stations updated"
|
||||||
}
|
}
|
||||||
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · tap a station for directions."
|
return "\(mode) \(fuel) within \(miles) \(unit) — \(ratio) stations updated · \(tapHint)"
|
||||||
}
|
}
|
||||||
return "\(mode) \(fuel) — \(ratio) stations updated · tap a station for directions."
|
return "\(mode) \(fuel) — \(ratio) stations updated · \(tapHint)"
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -85,7 +102,7 @@ struct StationsView: View {
|
|||||||
|
|
||||||
Section("Stations") {
|
Section("Stations") {
|
||||||
Picker("Sort by", selection: $sortMode) {
|
Picker("Sort by", selection: $sortMode) {
|
||||||
ForEach(SortMode.allCases) { mode in
|
ForEach(availableSortModes) { mode in
|
||||||
Text(mode.displayName).tag(mode)
|
Text(mode.displayName).tag(mode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,7 +112,17 @@ struct StationsView: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.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,
|
||||||
|
referencePricePencePerLitre: baselinePrice
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLoading && stations.isEmpty {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
Text("Fetching prices…")
|
Text("Fetching prices…")
|
||||||
@@ -126,7 +153,12 @@ struct StationsView: View {
|
|||||||
baselinePrice: baselinePrice,
|
baselinePrice: baselinePrice,
|
||||||
isTopResult: station.id == topStationID,
|
isTopResult: station.id == topStationID,
|
||||||
isFavourite: favouriteIDs.contains(station.id),
|
isFavourite: favouriteIDs.contains(station.id),
|
||||||
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
|
topBadgeText: sortMode == .worthTheDrive ? "CHEAPEST" : "TOP",
|
||||||
|
tapAction: stationTapAction,
|
||||||
|
fuelSavings: savingsForStation(station),
|
||||||
|
fuelSavingsAmount: fuelSavingsAmount,
|
||||||
|
onToggleFavourite: { onToggleFavourite(station, selectedFuel) },
|
||||||
|
onShowDetails: { detailStation = $0 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +203,12 @@ struct StationsView: View {
|
|||||||
// large title at rest, scrolls it away and fades the compact
|
// large title at rest, scrolls it away and fades the compact
|
||||||
// inline title in. No custom toolbar needed — the native mechanism
|
// inline title in. No custom toolbar needed — the native mechanism
|
||||||
// only renders text, which is exactly what we want here.
|
// 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")
|
.navigationTitle("FuelBoard")
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -180,6 +218,18 @@ struct StationsView: View {
|
|||||||
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
|
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
|
||||||
showKey = true
|
showKey = 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
|
#endif
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -260,8 +310,29 @@ struct StationsView: View {
|
|||||||
.presentationDetents([.fraction(0.6)])
|
.presentationDetents([.fraction(0.6)])
|
||||||
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||||
}
|
}
|
||||||
|
.sheet(item: $detailStation) { station in
|
||||||
|
StationDetailView(
|
||||||
|
station: station,
|
||||||
|
location: location,
|
||||||
|
distanceUnit: distanceUnit,
|
||||||
|
fuelSavings: savingsForStation(station),
|
||||||
|
fuelSavingsAmount: fuelSavingsAmount,
|
||||||
|
savingsBaselineStationName: savingsBaselineStationName,
|
||||||
|
journeyDiagnostics: journeyDiagnosticsForStation(station),
|
||||||
|
isFavourite: favouriteIDs.contains(station.id),
|
||||||
|
onToggleFavourite: { onToggleFavourite(station, selectedFuel) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
/// One price-rating card for the Key sheet: accent icon, title + blurb,
|
||||||
/// and the rating threshold in the accent colour (mirrors the tip cards).
|
/// and the rating threshold in the accent colour (mirrors the tip cards).
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ import WatchConnectivity
|
|||||||
|
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
|
static let fuelBoardWatchRefreshRequested = Notification.Name("FuelBoardWatchRefreshRequested")
|
||||||
|
static let fuelBoardShowStationDetail = Notification.Name("FuelBoardShowStationDetail")
|
||||||
|
/// 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
|
@MainActor
|
||||||
@@ -10,6 +21,7 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
|
|||||||
static let shared = WatchSyncManager()
|
static let shared = WatchSyncManager()
|
||||||
|
|
||||||
private var pendingSnapshotPush = false
|
private var pendingSnapshotPush = false
|
||||||
|
private var activationRequested = false
|
||||||
|
|
||||||
private override init() {
|
private override init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -17,19 +29,28 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
|
|||||||
|
|
||||||
func activate() {
|
func activate() {
|
||||||
guard WCSession.isSupported() else { return }
|
guard WCSession.isSupported() else { return }
|
||||||
pendingSnapshotPush = true
|
|
||||||
let session = WCSession.default
|
let session = WCSession.default
|
||||||
|
guard session.isWatchAppInstalled else { return }
|
||||||
|
pendingSnapshotPush = true
|
||||||
if session.delegate !== self {
|
if session.delegate !== self {
|
||||||
session.delegate = self
|
session.delegate = self
|
||||||
}
|
}
|
||||||
|
guard session.activationState != .activated else { return }
|
||||||
|
guard !activationRequested else { return }
|
||||||
|
activationRequested = true
|
||||||
session.activate()
|
session.activate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func pushSnapshot() {
|
func pushSnapshot() {
|
||||||
guard WCSession.isSupported() else { return }
|
guard WCSession.isSupported() else { return }
|
||||||
let session = WCSession.default
|
let session = WCSession.default
|
||||||
|
guard session.isWatchAppInstalled else {
|
||||||
|
pendingSnapshotPush = false
|
||||||
|
return
|
||||||
|
}
|
||||||
guard session.activationState == .activated else {
|
guard session.activationState == .activated else {
|
||||||
pendingSnapshotPush = true
|
pendingSnapshotPush = true
|
||||||
|
activate()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
@@ -72,6 +93,9 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
|
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
|
||||||
|
Task { @MainActor in
|
||||||
|
self.activationRequested = false
|
||||||
|
}
|
||||||
if let error {
|
if let error {
|
||||||
print("WATCH-SYNC activation failed: \(error.localizedDescription)")
|
print("WATCH-SYNC activation failed: \(error.localizedDescription)")
|
||||||
return
|
return
|
||||||
@@ -87,7 +111,8 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
|
|||||||
|
|
||||||
nonisolated func sessionDidDeactivate(_ session: WCSession) {
|
nonisolated func sessionDidDeactivate(_ session: WCSession) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
WCSession.default.activate()
|
self.activationRequested = false
|
||||||
|
self.activate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,14 +66,25 @@ struct WidgetMockScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Real stations (loaded dump), cheapest-first for the chosen fuel — the
|
/// Real stations (loaded dump) for the widget's DEFAULT configuration —
|
||||||
/// same selection the widget's own provider makes.
|
/// Unleaded / Cheapest / 5 miles — with the SAME radius test the widget's
|
||||||
|
/// provider applies. Without it this preview happily listed the UK-wide
|
||||||
|
/// cheapest (Banbridge, 184 mi away) under "Cheapest fuel near you", which
|
||||||
|
/// is not what the widget ever shows.
|
||||||
private func mockEntry(family: WidgetFamily) -> FuelPriceWidgetEntryData {
|
private func mockEntry(family: WidgetFamily) -> FuelPriceWidgetEntryData {
|
||||||
let fuel = FuelType.e10
|
let fuel = FuelType.e10
|
||||||
let all = FuelStore.loadStations()
|
|
||||||
let selling = all.filter { $0.prices[fuel] != nil }
|
|
||||||
.sorted { ($0.prices[fuel] ?? .infinity) < ($1.prices[fuel] ?? .infinity) }
|
|
||||||
let location = FuelStore.loadLocation().map { Coordinate(lat: $0.lat, lng: $0.lng) }
|
let location = FuelStore.loadLocation().map { Coordinate(lat: $0.lat, lng: $0.lng) }
|
||||||
|
// 5 miles — the widget config's default Distance. WidgetDistance itself
|
||||||
|
// lives in the widget target, so the preview mirrors its default here.
|
||||||
|
let radiusKM = 5 * 1.60934
|
||||||
|
let all = FuelStore.loadStations()
|
||||||
|
let selling = all.filter { station in
|
||||||
|
guard station.prices[fuel] != nil else { return false }
|
||||||
|
guard let location else { return true }
|
||||||
|
return FuelStore.isWithinDisplayRadius(station: station, radiusKM: radiusKM,
|
||||||
|
userLat: location.lat, userLng: location.lng)
|
||||||
|
}
|
||||||
|
.sorted { ($0.prices[fuel] ?? .infinity) < ($1.prices[fuel] ?? .infinity) }
|
||||||
let count = family == .systemSmall ? 1 : 3
|
let count = family == .systemSmall ? 1 : 3
|
||||||
return FuelPriceWidgetEntryData(
|
return FuelPriceWidgetEntryData(
|
||||||
date: Date(),
|
date: Date(),
|
||||||
|
|||||||
@@ -31,6 +31,9 @@
|
|||||||
"More than 4p over the cheapest" = "More than 4p over the cheapest";
|
"More than 4p over the cheapest" = "More than 4p over the cheapest";
|
||||||
"Top result for the current sort" = "Top result for the current sort";
|
"Top result for the current sort" = "Top result for the current sort";
|
||||||
"Star a station to add it to Favourites" = "Star a station to add it to Favourites";
|
"Star a station to add it to Favourites" = "Star a station to add it to Favourites";
|
||||||
|
/* Star button in the More info sheet heading (accessibility labels) */
|
||||||
|
"Add to favourites" = "Add to favourites";
|
||||||
|
"Remove from favourites" = "Remove from favourites";
|
||||||
"Colours match each station's rating on the list" = "Colours match each station's rating on the list";
|
"Colours match each station's rating on the list" = "Colours match each station's rating on the list";
|
||||||
|
|
||||||
/* Favourites tab */
|
/* Favourites tab */
|
||||||
@@ -56,6 +59,19 @@
|
|||||||
"Units" = "Units";
|
"Units" = "Units";
|
||||||
"Price display" = "Price display";
|
"Price display" = "Price display";
|
||||||
"Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L)." = "Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).";
|
"Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L)." = "Distances and search radii across the app, widget and alerts are shown in this unit. Prices can be shown as on a station sign (129.9) or in pounds and pence (£1.29⁹/L).";
|
||||||
|
"Tap station" = "Tap station";
|
||||||
|
"Tap action" = "Tap action";
|
||||||
|
"Tapping a station either opens directions straight away or shows its details first." = "Tapping a station either opens directions straight away or shows its details first.";
|
||||||
|
"Open map" = "Open map";
|
||||||
|
"More info" = "More info";
|
||||||
|
"Name" = "Name";
|
||||||
|
"Address" = "Address";
|
||||||
|
"Distance" = "Distance";
|
||||||
|
"Copied" = "Copied";
|
||||||
|
"Copies the postcode" = "Copies the postcode";
|
||||||
|
"Address: %@" = "Address: %@";
|
||||||
|
"Directions to %@" = "Directions to %@";
|
||||||
|
"Opens Apple Maps with driving directions" = "Opens Apple Maps with driving directions";
|
||||||
"Show introduction" = "Replay onboarding";
|
"Show introduction" = "Replay onboarding";
|
||||||
"Replay the welcome screen, including the location and notification permission prompts." = "Replay the welcome screen, including the location and notification permission prompts.";
|
"Replay the welcome screen, including the location and notification permission prompts." = "Replay the welcome screen, including the location and notification permission prompts.";
|
||||||
"Test alert notification (real data)" = "Test alert notification (real data)";
|
"Test alert notification (real data)" = "Test alert notification (real data)";
|
||||||
@@ -139,6 +155,12 @@
|
|||||||
"You" = "You";
|
"You" = "You";
|
||||||
"Done" = "Done";
|
"Done" = "Done";
|
||||||
"Directions" = "Directions";
|
"Directions" = "Directions";
|
||||||
|
"Report issue" = "Report issue";
|
||||||
|
"Membership required" = "Membership required";
|
||||||
|
"Report an error?" = "Report an error?";
|
||||||
|
"Cancel" = "Cancel";
|
||||||
|
"Report" = "Report";
|
||||||
|
"Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser." = "Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.";
|
||||||
|
|
||||||
/* Station row */
|
/* Station row */
|
||||||
"best" = "best";
|
"best" = "best";
|
||||||
@@ -168,3 +190,25 @@
|
|||||||
"Check your internet connection" = "Check your internet connection";
|
"Check your internet connection" = "Check your internet connection";
|
||||||
"Tap to try again" = "Tap to try again";
|
"Tap to try again" = "Tap to try again";
|
||||||
"Check your internet connection. Tap to try again" = "Check your internet connection. Tap to try again";
|
"Check your internet connection. Tap to try again" = "Check your internet connection. Tap to try again";
|
||||||
|
|
||||||
|
/* Alerts and favourites localisation pass */
|
||||||
|
"Cheapest favourite" = "Cheapest favourite";
|
||||||
|
"Check now" = "Check now";
|
||||||
|
"Price-drop alerts" = "Price-drop alerts";
|
||||||
|
"Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise." = "Watches your cheapest saved favourite for the fuel you pick here. FuelBoard alerts when a different favourite becomes the cheapest, or when your current cheapest favourite drops by at least 1.0p. This is separate from the nearby-geofence alert, so you can monitor favourites without extra location noise.";
|
||||||
|
"Cheapest station" = "Cheapest station";
|
||||||
|
"Alert type" = "Alert type";
|
||||||
|
"Best Value" = "Best Value";
|
||||||
|
"Minimum saving" = "Minimum saving";
|
||||||
|
"Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost." = "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.";
|
||||||
|
"Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost." = "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.";
|
||||||
|
"When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour." = "When you approach the Best Value station within the radius, FuelBoard sends a notification if it could leave you at least %@ better off after extra return travel. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour.";
|
||||||
|
"Proximity alerts" = "Proximity alerts";
|
||||||
|
"When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar." = "When you approach a station that is the cheapest within the radius, FuelBoard sends a notification — even with the app closed. Nearby stations selling %@ are watched — favourites get priority — and each station alerts at most once per hour. Follow search mirrors the Stations-tab distance, capped at 8 miles for reliable geofencing. Smaller radii ping more often but later; larger radii ping rarely in town but earlier from afar.";
|
||||||
|
"Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing)." = "Set-and-forget: alerts behave exactly like the Stations-tab search distance (capped at 8 miles for reliable geofencing).";
|
||||||
|
"Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips." = "Town driving. Fires most often — you're rarely inside the circle for long, so pings are meaningful. Best for stop-start local trips.";
|
||||||
|
"City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town." = "City or suburb commute. Slightly earlier heads-up than the smallest option; still fires regularly around town.";
|
||||||
|
"Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns." = "Mixed driving. Middle ground: heads-up a few minutes out, still works in most towns.";
|
||||||
|
"A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle." = "A-roads and motorway exits. Wide early warning for longer trips — but in town it rarely fires, because you're usually already inside the circle.";
|
||||||
|
"Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires." = "Long-distance journeys. Maximum reach: a ping when a cheap station is coming up from far out. In town this essentially never fires.";
|
||||||
|
"%@ — %@" = "%@ — %@";
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../Shared/FuelSavings.swift
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../Shared/StationAddress.swift
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import FuelBoardShared
|
||||||
|
|
||||||
|
/// The Best Value announcement gate — shown to users who updated into the
|
||||||
|
/// feature after finishing onboarding, once per announcement version.
|
||||||
|
///
|
||||||
|
/// Every fact the rule depends on is injectable, so the whole truth table is
|
||||||
|
/// checked here instead of driving a simulator through four combinations (which
|
||||||
|
/// is how a rule like this quietly rots).
|
||||||
|
final class BestValueAnnouncementTests: XCTestCase {
|
||||||
|
|
||||||
|
/// Nothing set up: the case that should be announced to.
|
||||||
|
private var unconfigured: FuelSavingsSettings { FuelSavingsSettings.defaults }
|
||||||
|
|
||||||
|
/// Enabled with the default 45 MPG / £40, which is a valid configuration.
|
||||||
|
private var configured: FuelSavingsSettings {
|
||||||
|
var settings = FuelSavingsSettings.defaults
|
||||||
|
settings.enabled = true
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAnnouncesToAnExistingUserWhoHasNotSeenIt() {
|
||||||
|
XCTAssertTrue(
|
||||||
|
FuelStore.shouldAnnounceBestValue(settings: unconfigured,
|
||||||
|
hasSeen: false,
|
||||||
|
onboardingDue: false,
|
||||||
|
isSuppressed: false),
|
||||||
|
"an existing user, nothing configured, announcement unseen: show it"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNeverAnnouncesTwice() {
|
||||||
|
XCTAssertFalse(FuelStore.shouldAnnounceBestValue(settings: unconfigured,
|
||||||
|
hasSeen: true,
|
||||||
|
onboardingDue: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNeverWhileOnboardingIsDue() {
|
||||||
|
// A new install runs the full onboarding, which already contains this
|
||||||
|
// page — showing both would be the obvious first bug.
|
||||||
|
XCTAssertFalse(FuelStore.shouldAnnounceBestValue(settings: unconfigured,
|
||||||
|
hasSeen: false,
|
||||||
|
onboardingDue: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNeverWhileOnboardingIsOnScreen() {
|
||||||
|
XCTAssertFalse(FuelStore.shouldAnnounceBestValue(settings: unconfigured,
|
||||||
|
hasSeen: false,
|
||||||
|
onboardingDue: false,
|
||||||
|
isSuppressed: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNeverForSomebodyWhoAlreadyConfiguredIt() {
|
||||||
|
XCTAssertFalse(FuelStore.shouldAnnounceBestValue(settings: configured,
|
||||||
|
hasSeen: false,
|
||||||
|
onboardingDue: false),
|
||||||
|
"never announce a feature the user set up themselves")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStillAnnouncesWhenEnabledButNotActuallyConfigured() {
|
||||||
|
// Enabled with an out-of-range MPG is NOT configured, so the
|
||||||
|
// announcement still has something useful to offer.
|
||||||
|
var settings = FuelSavingsSettings.defaults
|
||||||
|
settings.enabled = true
|
||||||
|
settings.mpg = 2 // below the validity floor
|
||||||
|
XCTAssertTrue(FuelStore.shouldAnnounceBestValue(settings: settings,
|
||||||
|
hasSeen: false,
|
||||||
|
onboardingDue: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMarkingSeenIsWhatStopsTheNextLaunch() {
|
||||||
|
// The stored contract the gate reads.
|
||||||
|
FuelStore.markBestValueIntroSeen()
|
||||||
|
XCTAssertTrue(FuelStore.hasSeenBestValueIntro())
|
||||||
|
XCTAssertFalse(FuelStore.shouldAnnounceBestValue(settings: unconfigured,
|
||||||
|
onboardingDue: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTheStoredValueIsTheAnnouncementVersionNotTheAppVersion() {
|
||||||
|
// Keyed on the announcement version so a bugfix release cannot
|
||||||
|
// re-announce anything. Bump `currentBestValueIntroVersion` only when
|
||||||
|
// Best Value changes in a way worth re-explaining — this assertion is
|
||||||
|
// the reminder that doing so re-shows the page to everyone.
|
||||||
|
XCTAssertEqual(FuelStore.currentBestValueIntroVersion, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
@testable import FuelBoardShared
|
@testable import FuelBoardShared
|
||||||
|
#endif
|
||||||
|
|
||||||
// FuelBoard shared-logic tests. Run from FuelBoardTests/ with `swift test`.
|
// FuelBoard shared-logic tests. Run from FuelBoardTests/ with `swift test`.
|
||||||
// Covers the pure logic that drives the app + widget: title sanitizer, price
|
// Covers the pure logic that drives the app + widget: title sanitizer, price
|
||||||
@@ -630,6 +632,9 @@ final class RoadDistanceCacheTests: XCTestCase {
|
|||||||
// test or run would pollute these. Overwrite with an empty, far-away
|
// test or run would pollute these. Overwrite with an empty, far-away
|
||||||
// cache (source at (0,0)) so every test starts from a clean slate.
|
// cache (source at (0,0)) so every test starts from a clean slate.
|
||||||
FuelStore.saveRoadDistances(sourceLat: 0, sourceLng: 0, entries: [:])
|
FuelStore.saveRoadDistances(sourceLat: 0, sourceLng: 0, entries: [:])
|
||||||
|
// "Accurate distances" is a persisted setting, so a previous run (or a
|
||||||
|
// test that turned it on) would leak into these. Off is the default.
|
||||||
|
FuelStore.setAccurateDistancesEnabled(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func station(_ id: String, _ lat: Double, _ lng: Double) -> FuelStation {
|
private func station(_ id: String, _ lat: Double, _ lng: Double) -> FuelStation {
|
||||||
@@ -645,6 +650,7 @@ final class RoadDistanceCacheTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testRoadDistanceUsedWhenCachedNear() {
|
func testRoadDistanceUsedWhenCachedNear() {
|
||||||
|
FuelStore.setAccurateDistancesEnabled(true)
|
||||||
let s = station("a", 51.5074, -0.1278)
|
let s = station("a", 51.5074, -0.1278)
|
||||||
// Cache a road distance of 3.2 km for this station from the user's fix.
|
// Cache a road distance of 3.2 km for this station from the user's fix.
|
||||||
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
@@ -654,6 +660,7 @@ final class RoadDistanceCacheTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testRoadDistanceNilWhenOriginFar() {
|
func testRoadDistanceNilWhenOriginFar() {
|
||||||
|
FuelStore.setAccurateDistancesEnabled(true)
|
||||||
let s = station("a", 51.5074, -0.1278)
|
let s = station("a", 51.5074, -0.1278)
|
||||||
// Cache built in London, but the user is now ~200 km away -> stale.
|
// Cache built in London, but the user is now ~200 km away -> stale.
|
||||||
FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278,
|
FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278,
|
||||||
@@ -666,6 +673,7 @@ final class RoadDistanceCacheTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testRoadDistanceUsedForOtherStationNotFound() {
|
func testRoadDistanceUsedForOtherStationNotFound() {
|
||||||
|
FuelStore.setAccurateDistancesEnabled(true)
|
||||||
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||||
// A station that isn't in the cache falls back to straight-line.
|
// A station that isn't in the cache falls back to straight-line.
|
||||||
@@ -675,6 +683,7 @@ final class RoadDistanceCacheTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testRoadDistanceNotServedWhenStationPinDiffers() {
|
func testRoadDistanceNotServedWhenStationPinDiffers() {
|
||||||
|
FuelStore.setAccurateDistancesEnabled(true)
|
||||||
// Route a road distance to station "a" at pin P1.
|
// Route a road distance to station "a" at pin P1.
|
||||||
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||||
@@ -686,6 +695,347 @@ final class RoadDistanceCacheTests: XCTestCase {
|
|||||||
XCTAssertEqual(km, moved.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001,
|
XCTAssertEqual(km, moved.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001,
|
||||||
"road value routed to the old pin leaked onto a different coordinate")
|
"road value routed to the old pin leaked onto a different coordinate")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A partial cache legitimately mixes road and straight-line distances for
|
||||||
|
/// the SAVINGS maths (Best Value marks the estimated ones itself), but the
|
||||||
|
/// browse layer only ever serves a COMPLETE set — see the accurate-distances
|
||||||
|
/// tests below.
|
||||||
|
func testPartialRoadCacheCanMixRoadAndStraightLineDistances() {
|
||||||
|
FuelStore.setAccurateDistancesEnabled(true)
|
||||||
|
let routed = station("routed", 51.5074, -0.1278)
|
||||||
|
let estimated = station("estimated", 51.5100, -0.1300)
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["routed": .init(meters: 3200, lat: 51.5074, lng: -0.1278, returnMeters: 3600)])
|
||||||
|
|
||||||
|
XCTAssertEqual(FuelStore.roadDistanceMeters(for: routed, userLat: 51.6, userLng: -0.1), 3200)
|
||||||
|
XCTAssertNil(FuelStore.roadDistanceMeters(for: estimated, userLat: 51.6, userLng: -0.1))
|
||||||
|
XCTAssertEqual(FuelStore.displayDistanceKM(station: routed, userLat: 51.6, userLng: -0.1), 3.2, accuracy: 0.0001)
|
||||||
|
XCTAssertEqual(FuelStore.displayDistanceKM(station: estimated, userLat: 51.6, userLng: -0.1),
|
||||||
|
estimated.distanceKM(to: 51.6, lng2: -0.1),
|
||||||
|
accuracy: 0.0001)
|
||||||
|
XCTAssertEqual(FuelStore.roadJourneyMeters(for: routed, userLat: 51.6, userLng: -0.1), 6800,
|
||||||
|
"outward and return road routes are combined once, not doubled")
|
||||||
|
XCTAssertTrue(FuelStore.hasMeasuredReturnRoadDistance(for: routed, userLat: 51.6, userLng: -0.1))
|
||||||
|
XCTAssertFalse(FuelStore.hasMeasuredReturnRoadDistance(for: estimated, userLat: 51.6, userLng: -0.1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reported bug: "10 miles cheapest" listed Jet Chidswell at 14.6 mi.
|
||||||
|
/// Its straight line was inside 10 miles, its routed distance was not — the
|
||||||
|
/// filter tested the former while the row displayed the latter.
|
||||||
|
func testRadiusFilterUsesTheDisplayedDistanceNotTheStraightLine() {
|
||||||
|
FuelStore.setAccurateDistancesEnabled(true)
|
||||||
|
let sourceLat = 51.6, sourceLng = -0.1
|
||||||
|
let far = station("far", 51.5074, -0.1278) // in by straight line…
|
||||||
|
let near = station("near", 51.5400, -0.1000)
|
||||||
|
FuelStore.saveRoadDistances(
|
||||||
|
sourceLat: sourceLat, sourceLng: sourceLng,
|
||||||
|
entries: ["far": .init(meters: 23_500, lat: 51.5074, lng: -0.1278, returnMeters: 23_500)])
|
||||||
|
|
||||||
|
let tenMilesKM = 10 * 1.60934
|
||||||
|
XCTAssertLessThan(far.distanceKM(to: sourceLat, lng2: sourceLng), tenMilesKM,
|
||||||
|
"fixture: the straight line must sit INSIDE the 10-mile radius")
|
||||||
|
XCTAssertGreaterThan(FuelStore.displayDistanceKM(station: far, userLat: sourceLat, userLng: sourceLng),
|
||||||
|
tenMilesKM, "fixture: the routed distance must sit OUTSIDE it")
|
||||||
|
|
||||||
|
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: near, radiusKM: tenMilesKM,
|
||||||
|
userLat: sourceLat, userLng: sourceLng))
|
||||||
|
XCTAssertFalse(FuelStore.isWithinDisplayRadius(station: far, radiusKM: tenMilesKM,
|
||||||
|
userLat: sourceLat, userLng: sourceLng),
|
||||||
|
"a station whose row reads 14.6 mi must not appear in a 10-mile list")
|
||||||
|
// The same station is back once the radius actually covers its distance.
|
||||||
|
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: far, radiusKM: 15 * 1.60934,
|
||||||
|
userLat: sourceLat, userLng: sourceLng))
|
||||||
|
// …and an unknown station (no cached route) still tests its displayed
|
||||||
|
// straight line, so nothing changes for the bulk of the list.
|
||||||
|
let unrouted = station("unrouted", 51.5400, -0.1000)
|
||||||
|
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: unrouted, radiusKM: tenMilesKM,
|
||||||
|
userLat: sourceLat, userLng: sourceLng))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLegacyOneWayRoadCacheIsUsableButNotConfirmed() {
|
||||||
|
let legacy = station("legacy", 51.5074, -0.1278)
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["legacy": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
|
||||||
|
|
||||||
|
XCTAssertEqual(FuelStore.roadJourneyMeters(for: legacy, userLat: 51.6, userLng: -0.1), 6400)
|
||||||
|
XCTAssertFalse(FuelStore.hasMeasuredReturnRoadDistance(for: legacy, userLat: 51.6, userLng: -0.1),
|
||||||
|
"legacy one-way cache entries infer the return leg and must remain estimated")
|
||||||
|
let diagnostics = FuelStore.roadDistanceDiagnostics(for: legacy, userLat: 51.6, userLng: -0.1)
|
||||||
|
XCTAssertEqual(diagnostics.displaySource, .roadRoute)
|
||||||
|
XCTAssertEqual(diagnostics.outwardMeters, 3200)
|
||||||
|
XCTAssertNil(diagnostics.returnMeters)
|
||||||
|
XCTAssertEqual(diagnostics.totalJourneyMeters, 6400)
|
||||||
|
XCTAssertFalse(diagnostics.hasMeasuredReturn)
|
||||||
|
XCTAssertTrue(diagnostics.hasValidCacheEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A station distance the user can see is marked "Approx." until a routed road
|
||||||
|
/// distance exists for it. Figure and marker come out of ONE cache read, so a
|
||||||
|
/// surface can never show a routed value labelled approximate (or the reverse).
|
||||||
|
func testStationDistanceIsMarkedApproximateUntilRoutingLands() {
|
||||||
|
FuelStore.setAccurateDistancesEnabled(true)
|
||||||
|
let routed = station("routed", 51.5074, -0.1278)
|
||||||
|
let unrouted = station("unrouted", 51.5200, -0.1400)
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["routed": .init(meters: 3200, lat: 51.5074, lng: -0.1278, returnMeters: 3600)])
|
||||||
|
|
||||||
|
let confirmed = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
|
||||||
|
XCTAssertFalse(confirmed.isApproximate, "a cached road distance is not an approximation")
|
||||||
|
XCTAssertEqual(confirmed.km, 3.2, accuracy: 0.0001)
|
||||||
|
XCTAssertEqual(confirmed.label(unit: .miles), "2.0 mi")
|
||||||
|
XCTAssertEqual(confirmed.labelWithReturn(unit: .miles), "2.0 mi (4.0 mi return)")
|
||||||
|
XCTAssertEqual(confirmed.labelWithReturn(unit: .kilometers), "3.2 km (6.4 km return)")
|
||||||
|
XCTAssertEqual(confirmed.spokenLabel(unit: .miles), "2.0 miles")
|
||||||
|
XCTAssertEqual(confirmed.spokenLabelWithReturn(unit: .miles), "2.0 miles, 4.0 miles return")
|
||||||
|
|
||||||
|
let approximate = FuelStore.stationDistanceDisplay(station: unrouted, userLat: 51.6, userLng: -0.1)
|
||||||
|
XCTAssertTrue(approximate.isApproximate, "the straight-line fallback must be identified as one")
|
||||||
|
XCTAssertEqual(approximate.km, unrouted.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
|
||||||
|
XCTAssertEqual(approximate.label(unit: .miles),
|
||||||
|
"Approx. " + DistanceUnit.miles.format(approximate.km))
|
||||||
|
XCTAssertEqual(approximate.labelWithReturn(unit: .miles),
|
||||||
|
"Approx. \(DistanceUnit.miles.format(approximate.km)) (\(DistanceUnit.miles.format(approximate.km * 2)) return)")
|
||||||
|
XCTAssertEqual(approximate.spokenLabel(unit: .kilometers),
|
||||||
|
"Approximately " + DistanceUnit.kilometers.spoken(approximate.km))
|
||||||
|
XCTAssertEqual(approximate.spokenLabelWithReturn(unit: .kilometers),
|
||||||
|
"Approximately \(DistanceUnit.kilometers.spoken(approximate.km)), \(DistanceUnit.kilometers.spoken(approximate.km * 2)) return")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The marker tracks the cache on its own: same station, bare figure once its
|
||||||
|
/// route lands, "Approx." again once the origin moves out of tolerance.
|
||||||
|
func testStationDistanceLabelFollowsTheRoadCache() {
|
||||||
|
FuelStore.setAccurateDistancesEnabled(true)
|
||||||
|
let moved = station("moved", 51.5074, -0.1278)
|
||||||
|
XCTAssertTrue(FuelStore.stationDistanceDisplay(station: moved, userLat: 51.6, userLng: -0.1).isApproximate,
|
||||||
|
"nothing has been routed yet")
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["moved": .init(meters: 3200, lat: 51.5074, lng: -0.1278, returnMeters: 3600)])
|
||||||
|
XCTAssertFalse(FuelStore.stationDistanceDisplay(station: moved, userLat: 51.6, userLng: -0.1).isApproximate,
|
||||||
|
"route data landed, so the marker must drop without any other change")
|
||||||
|
XCTAssertTrue(FuelStore.stationDistanceDisplay(station: moved, userLat: 59.0, userLng: -0.1).isApproximate,
|
||||||
|
"a cache built from another origin must not confirm this one")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Display origin across processes (widget extension fix vs app fix)
|
||||||
|
|
||||||
|
/// The reported bug: the iPhone SE widget kept reading "Approx. 1.5 mi" for
|
||||||
|
/// the nearest stations while the app showed the SAME figure as a routed road
|
||||||
|
/// distance. The widget extension gets its own fix from its own
|
||||||
|
/// CLLocationManager and it is usually coarse — hundreds of metres from the
|
||||||
|
/// app's GPS fix that the routes were anchored to. That must not be read as
|
||||||
|
/// "the user moved away from the cache" and collapse every row.
|
||||||
|
func testCoarseWidgetFixDoesNotDowngradeRoutedDistances() {
|
||||||
|
FuelStore.setAccurateDistancesEnabled(true)
|
||||||
|
let appLat = 51.6, appLng = -0.1
|
||||||
|
FuelStore.saveLocation(lat: appLat, lng: appLng)
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: appLat, sourceLng: appLng,
|
||||||
|
entries: ["a": .init(meters: 2344, lat: 51.5074, lng: -0.1278,
|
||||||
|
returnMeters: 2400)],
|
||||||
|
complete: true)
|
||||||
|
|
||||||
|
// The extension's own fix: ~700 m from the app's anchor, reported coarse.
|
||||||
|
let widgetLat = 51.6063
|
||||||
|
let offsetKM = station("anchor", appLat, appLng).distanceKM(to: widgetLat, lng2: appLng)
|
||||||
|
XCTAssertGreaterThan(offsetKM, FuelStore.roadDistanceOriginToleranceMeters / 1000,
|
||||||
|
"fixture: the widget fix must sit outside the origin ring")
|
||||||
|
XCTAssertLessThan(offsetKM, FuelStore.roadDistanceCoarseFixRadiusMeters / 1000,
|
||||||
|
"fixture: and inside the coarse-fix radius")
|
||||||
|
|
||||||
|
let origin = FuelStore.distanceDisplayOrigin(candidateLat: widgetLat, candidateLng: appLng,
|
||||||
|
candidateAccuracyMeters: 500)
|
||||||
|
XCTAssertTrue(origin.routed, "a coarse widget fix must not invalidate the routed set")
|
||||||
|
XCTAssertEqual(origin.source, "appFix")
|
||||||
|
XCTAssertEqual(origin.lat, appLat, accuracy: 0.000001)
|
||||||
|
XCTAssertEqual(origin.lng, appLng, accuracy: 0.000001)
|
||||||
|
|
||||||
|
let shown = FuelStore.stationDistanceDisplay(station: station("a", 51.5074, -0.1278),
|
||||||
|
userLat: origin.lat, userLng: origin.lng)
|
||||||
|
XCTAssertFalse(shown.isApproximate, "the nearest station must not read Approx. on the widget")
|
||||||
|
XCTAssertEqual(shown.km, 2.344, accuracy: 0.0001)
|
||||||
|
XCTAssertEqual(shown.label(unit: .miles), "1.5 mi")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sub-kilometre disagreement is fix noise whatever the reported accuracy:
|
||||||
|
/// two honest fixes of the same place can differ that much, and it is far
|
||||||
|
/// too small to justify re-labelling a whole list.
|
||||||
|
func testSubKilometreDisagreementKeepsRoutedDistances() {
|
||||||
|
FuelStore.setAccurateDistancesEnabled(true)
|
||||||
|
FuelStore.saveLocation(lat: 51.6, lng: -0.1)
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)],
|
||||||
|
complete: true)
|
||||||
|
|
||||||
|
let origin = FuelStore.distanceDisplayOrigin(candidateLat: 51.6063, candidateLng: -0.1,
|
||||||
|
candidateAccuracyMeters: 10)
|
||||||
|
XCTAssertTrue(origin.routed)
|
||||||
|
XCTAssertEqual(origin.source, "appFix")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A PRECISE fix far from the anchor IS the user having moved. The honest
|
||||||
|
/// answer stays the straight line, marked Approx., until the app re-routes
|
||||||
|
/// from the new position.
|
||||||
|
func testPreciseFixAwayFromTheAnchorStillFallsBackToApproximate() {
|
||||||
|
FuelStore.setAccurateDistancesEnabled(true)
|
||||||
|
FuelStore.saveLocation(lat: 51.6, lng: -0.1)
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)],
|
||||||
|
complete: true)
|
||||||
|
|
||||||
|
let origin = FuelStore.distanceDisplayOrigin(candidateLat: 51.78, candidateLng: -0.1,
|
||||||
|
candidateAccuracyMeters: 10)
|
||||||
|
XCTAssertFalse(origin.routed)
|
||||||
|
XCTAssertEqual(origin.source, "straightLine")
|
||||||
|
XCTAssertEqual(origin.lat, 51.78, accuracy: 0.000001)
|
||||||
|
|
||||||
|
let shown = FuelStore.stationDistanceDisplay(station: station("a", 51.5074, -0.1278),
|
||||||
|
userLat: origin.lat, userLng: origin.lng)
|
||||||
|
XCTAssertTrue(shown.isApproximate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rescue only applies where the stored app fix actually backs the cache:
|
||||||
|
/// an app fix somewhere else cannot confirm routes for this neighbourhood.
|
||||||
|
func testCoarseFixIsNotRescuedWhenTheStoredAppFixIsElsewhere() {
|
||||||
|
FuelStore.setAccurateDistancesEnabled(true)
|
||||||
|
FuelStore.saveLocation(lat: 53.4808, lng: -2.2426) // app fix: Manchester
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)],
|
||||||
|
complete: true)
|
||||||
|
|
||||||
|
let origin = FuelStore.distanceDisplayOrigin(candidateLat: 51.6063, candidateLng: -0.1,
|
||||||
|
candidateAccuracyMeters: 500)
|
||||||
|
XCTAssertFalse(origin.routed)
|
||||||
|
XCTAssertEqual(origin.source, "straightLine")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Neither an incomplete fill nor the mode being off may resolve a routed
|
||||||
|
/// origin from any fix — same all-or-nothing rule as the rows.
|
||||||
|
func testRoutedOriginRequiresACompleteCacheAndTheModeOn() {
|
||||||
|
FuelStore.setAccurateDistancesEnabled(true)
|
||||||
|
FuelStore.saveLocation(lat: 51.6, lng: -0.1)
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)],
|
||||||
|
complete: false)
|
||||||
|
XCTAssertFalse(FuelStore.distanceDisplayOrigin(candidateLat: 51.6, candidateLng: -0.1,
|
||||||
|
candidateAccuracyMeters: 10).routed,
|
||||||
|
"an incomplete fill must never serve routed distances")
|
||||||
|
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)],
|
||||||
|
complete: true)
|
||||||
|
FuelStore.setAccurateDistancesEnabled(false)
|
||||||
|
XCTAssertFalse(FuelStore.distanceDisplayOrigin(candidateLat: 51.6, candidateLng: -0.1,
|
||||||
|
candidateAccuracyMeters: 10).routed,
|
||||||
|
"with the mode off the browse layer never serves routes")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The widget must record its own fix under its own key: overwriting the
|
||||||
|
/// app's GPS fix with a Wi-Fi-grade one left the app seeded with the wrong
|
||||||
|
/// position and broke the anchor comparison above.
|
||||||
|
func testWidgetFixIsStoredSeparatelyFromTheAppFix() {
|
||||||
|
FuelStore.saveLocation(lat: 51.6, lng: -0.1)
|
||||||
|
FuelStore.saveWidgetLocation(lat: 51.6063, lng: -0.1)
|
||||||
|
XCTAssertEqual(FuelStore.loadLocation()?.lat ?? 0, 51.6, accuracy: 0.000001,
|
||||||
|
"the app's GPS fix must survive a widget timeline")
|
||||||
|
XCTAssertEqual(FuelStore.loadWidgetLocation()?.coordinate.lat ?? 0, 51.6063, accuracy: 0.000001)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Accurate distances toggle (browse layer only)
|
||||||
|
|
||||||
|
/// The toggle must be opt-in: unset means off, so a fresh install makes no
|
||||||
|
/// route requests and every device shows the same straight-line figures.
|
||||||
|
func testAccurateDistancesDefaultsToOffWhenUnset() {
|
||||||
|
UserDefaults(suiteName: FuelStore.appGroupSuite)?.removeObject(forKey: FuelStore.accurateDistancesKey)
|
||||||
|
XCTAssertFalse(FuelStore.accurateDistancesEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mode OFF: a COMPLETE routed cache exists and the browse layer must still
|
||||||
|
/// show the straight line, marked Approx., for every row — while the savings
|
||||||
|
/// maths keeps using the measured routes. That boundary is the feature.
|
||||||
|
func testAccurateDistancesOffIgnoresCachedRoutes() {
|
||||||
|
let routed = station("routed", 51.5074, -0.1278)
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["routed": .init(meters: 23_500, lat: 51.5074,
|
||||||
|
lng: -0.1278, returnMeters: 23_500)],
|
||||||
|
complete: true)
|
||||||
|
|
||||||
|
let display = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
|
||||||
|
XCTAssertTrue(display.isApproximate, "the browse layer must never serve routes with the mode off")
|
||||||
|
XCTAssertEqual(display.km, routed.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
|
||||||
|
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: routed, radiusKM: 10 * 1.60934,
|
||||||
|
userLat: 51.6, userLng: -0.1),
|
||||||
|
"with the mode off the radius is a straight-line radius, agreeing with the rows")
|
||||||
|
|
||||||
|
XCTAssertEqual(FuelStore.roadJourneyMeters(for: routed, userLat: 51.6, userLng: -0.1), 47_000,
|
||||||
|
"Best Value keeps its measured routes whichever way the toggle is set")
|
||||||
|
XCTAssertTrue(FuelStore.hasMeasuredReturnRoadDistance(for: routed, userLat: 51.6, userLng: -0.1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An incomplete fill must never be half-served: the row that HAS a route
|
||||||
|
/// falls back too, so a list can't read "14.6 mi" beside rows that its radius
|
||||||
|
/// filter measured as straight lines.
|
||||||
|
func testAccurateDistancesOnWithIncompleteSetServesStraightLineForEveryRow() {
|
||||||
|
FuelStore.setAccurateDistancesEnabled(true)
|
||||||
|
let routed = station("routed", 51.5074, -0.1278)
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["routed": .init(meters: 23_500, lat: 51.5074,
|
||||||
|
lng: -0.1278, returnMeters: 23_500)],
|
||||||
|
complete: false)
|
||||||
|
|
||||||
|
let display = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
|
||||||
|
XCTAssertTrue(display.isApproximate)
|
||||||
|
XCTAssertEqual(display.km, routed.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001,
|
||||||
|
"a partial set must not be served, even for a station that has a route")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mode ON with a complete set from a location we are standing at: routed
|
||||||
|
/// values, unmarked, and the radius means miles of driving.
|
||||||
|
func testAccurateDistancesOnServesRoutedValuesWhenCompleteAndNear() {
|
||||||
|
FuelStore.setAccurateDistancesEnabled(true)
|
||||||
|
let routed = station("routed", 51.5074, -0.1278)
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["routed": .init(meters: 23_500, lat: 51.5074,
|
||||||
|
lng: -0.1278, returnMeters: 23_500)],
|
||||||
|
complete: true)
|
||||||
|
|
||||||
|
let display = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
|
||||||
|
XCTAssertFalse(display.isApproximate)
|
||||||
|
XCTAssertEqual(display.km, 23.5, accuracy: 0.0001)
|
||||||
|
XCTAssertFalse(FuelStore.isWithinDisplayRadius(station: routed, radiusKM: 10 * 1.60934,
|
||||||
|
userLat: 51.6, userLng: -0.1),
|
||||||
|
"in this mode a 10-mile radius is 10 miles of driving, matching the routed rows")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same complete cache, but the user has since driven away: nothing is served
|
||||||
|
/// (every row falls back together) rather than showing routes measured from a
|
||||||
|
/// place the user has left. Walking back inside tolerance serves it again
|
||||||
|
/// with no refill.
|
||||||
|
func testAccurateDistancesOnFallsBackTogetherWhenTheAnchorMoves() {
|
||||||
|
FuelStore.setAccurateDistancesEnabled(true)
|
||||||
|
let routed = station("routed", 51.5074, -0.1278)
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["routed": .init(meters: 23_500, lat: 51.5074,
|
||||||
|
lng: -0.1278, returnMeters: 23_500)],
|
||||||
|
complete: true)
|
||||||
|
let movedLat = 51.6 + 0.018 // ~2 km north, outside the 600 m tolerance
|
||||||
|
XCTAssertTrue(FuelStore.stationDistanceDisplay(station: routed, userLat: movedLat, userLng: -0.1).isApproximate)
|
||||||
|
XCTAssertFalse(FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1).isApproximate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The toggle is a browse-layer switch, not a data switch: Best Value's
|
||||||
|
/// journey metres and its measured-return confidence must be identical in
|
||||||
|
/// both positions.
|
||||||
|
func testAccurateDistancesSavingsMathsIsUnaffectedByTheToggle() {
|
||||||
|
let routed = station("routed", 51.5074, -0.1278)
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||||
|
entries: ["routed": .init(meters: 23_500, lat: 51.5074,
|
||||||
|
lng: -0.1278, returnMeters: 23_600)],
|
||||||
|
complete: true)
|
||||||
|
for mode in [false, true] {
|
||||||
|
FuelStore.setAccurateDistancesEnabled(mode)
|
||||||
|
XCTAssertEqual(FuelStore.roadJourneyMeters(for: routed, userLat: 51.6, userLng: -0.1), 47_100)
|
||||||
|
XCTAssertTrue(FuelStore.hasMeasuredReturnRoadDistance(for: routed, userLat: 51.6, userLng: -0.1))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Install identity
|
// MARK: - Install identity
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
@testable import FuelBoardShared
|
@testable import FuelBoardShared
|
||||||
|
#endif
|
||||||
|
|
||||||
// FuelHistory — price-history store for the Favourites Trends graph.
|
// FuelHistory — price-history store for the Favourites Trends graph.
|
||||||
// Covers day math, slim day parsing, series building, delta rebasing and
|
// Covers day math, slim day parsing, series building, delta rebasing and
|
||||||
|
|||||||
@@ -0,0 +1,419 @@
|
|||||||
|
import XCTest
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
|
@testable import FuelBoardShared
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Worth the Drive calculation audit.
|
||||||
|
///
|
||||||
|
/// Written against the audit brief (30 MPG imperial, £60 fill-up, baseline
|
||||||
|
/// Rontec Shelf at 171.9p, 0.4 miles). The brief's three observed results are
|
||||||
|
/// reproduced here exactly, so any future change to the formula breaks a test
|
||||||
|
/// instead of silently shifting the numbers on screen.
|
||||||
|
///
|
||||||
|
/// Deterministic journey distances only — never live MKDirections responses.
|
||||||
|
final class FuelSavingsCalculationAuditTests: XCTestCase {
|
||||||
|
private func station(_ id: String, price: Double?) -> FuelStation {
|
||||||
|
FuelStation(
|
||||||
|
id: id,
|
||||||
|
name: id,
|
||||||
|
brand: "X",
|
||||||
|
address: "",
|
||||||
|
postcode: "",
|
||||||
|
lat: 51.5,
|
||||||
|
lng: -0.1,
|
||||||
|
prices: price.map { [FuelType.e10: $0] } ?? [:],
|
||||||
|
priceUpdated: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Journeys are OUT AND BACK (the app's convention): the brief's baseline is
|
||||||
|
/// 0.4 miles away, so its journey is 0.8 miles, and "extra driving" of N
|
||||||
|
/// miles means a candidate journey of 0.8 + N.
|
||||||
|
private func journey(_ miles: Double, basis: FuelSavingsDistanceBasis = .roadRoute) -> FuelSavingsJourney {
|
||||||
|
FuelSavingsJourney(miles: miles, basis: basis)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let baselineJourneyMiles = 0.8
|
||||||
|
|
||||||
|
private func briefResult(candidatePrice: Double, extraMiles: Double) throws -> FuelSavingsResult {
|
||||||
|
try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 30,
|
||||||
|
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
|
||||||
|
baselinePricePencePerLitre: 171.9,
|
||||||
|
candidatePricePencePerLitre: candidatePrice,
|
||||||
|
baselineJourneyMiles: baselineJourneyMiles,
|
||||||
|
candidateJourneyMiles: baselineJourneyMiles + extraMiles
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 1. Unit conversion
|
||||||
|
|
||||||
|
func testImperialGallonIsUsedNotTheUSGallon() {
|
||||||
|
XCTAssertEqual(FuelSavingsCalculator.imperialGallonLitres, 4.54609)
|
||||||
|
XCTAssertNotEqual(FuelSavingsCalculator.imperialGallonLitres, 3.785411784,
|
||||||
|
"a US gallon must never be used: it would understate every travel cost")
|
||||||
|
|
||||||
|
// 30 miles at 30 MPG burns exactly one imperial gallon.
|
||||||
|
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 30, mpg: 30), 4.54609, accuracy: 1e-12)
|
||||||
|
// 100 miles at 50 MPG.
|
||||||
|
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 100, mpg: 50), 9.09218, accuracy: 1e-12)
|
||||||
|
// Zero distance burns nothing, and the relationship is linear in distance.
|
||||||
|
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 0, mpg: 30), 0, accuracy: 1e-12)
|
||||||
|
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 60, mpg: 30),
|
||||||
|
2 * FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 30, mpg: 30),
|
||||||
|
accuracy: 1e-12)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMileAndKilometreFactorsAreTheExactOnes() {
|
||||||
|
XCTAssertEqual(FuelSavingsCalculator.kilometresPerMile, 1.609344)
|
||||||
|
XCTAssertEqual(FuelSavingsCalculator.metresPerMile, 1609.344)
|
||||||
|
XCTAssertEqual(FuelSavingsCalculator.metresPerMile,
|
||||||
|
FuelSavingsCalculator.kilometresPerMile * 1000,
|
||||||
|
accuracy: 1e-9,
|
||||||
|
"the metre and kilometre factors must describe the same mile")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDistanceLabelUsesTheSameConversionAsTheModel() {
|
||||||
|
// One mile, shown in kilometres, goes through the single factor.
|
||||||
|
XCTAssertEqual(
|
||||||
|
FuelSavingsFormat.distance(1, unit: .kilometers),
|
||||||
|
DistanceUnit.kilometers.format(FuelSavingsCalculator.kilometresPerMile)
|
||||||
|
)
|
||||||
|
XCTAssertEqual(FuelSavingsFormat.distance(1, unit: .miles), DistanceUnit.miles.format(1.609344))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 2/4. The three stations from the brief
|
||||||
|
|
||||||
|
func testBriefCaseTescoBradfordIsMarginallyWorseOff() throws {
|
||||||
|
let result = try briefResult(candidatePrice: 170.9, extraMiles: 2.0)
|
||||||
|
|
||||||
|
XCTAssertEqual(result.fillLitres, 34.904014, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(result.grossSavingGBP, 0.349040, accuracy: 1e-6, "1p/litre cheaper on a 34.90 L fill")
|
||||||
|
XCTAssertEqual(result.extraDistanceMiles, 2.0, accuracy: 1e-9)
|
||||||
|
XCTAssertEqual(result.extraDrivingCostGBP, 0.517951, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(result.netSavingGBP, -0.168911, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(result.status, .notWorthTheDrive)
|
||||||
|
XCTAssertEqual(result.headline(), "£0.17 worse off after travel")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The brief recorded "£0.18 worse off" for this row against a displayed
|
||||||
|
/// "2.0 miles" of extra driving; the brief also warns that the displayed
|
||||||
|
/// mileage is rounded. This pins that explanation instead of treating it as
|
||||||
|
/// a calculation bug: at an extra distance the caption still shows as
|
||||||
|
/// "2.0 mi" (2.00-2.05), the headline reads £0.17-£0.18, and the difference
|
||||||
|
/// is entirely the rounding of the distance, not of the money.
|
||||||
|
func testTheReportedEighteenPenceIsRoundedExtraDistanceNotARoundingBug() throws {
|
||||||
|
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.00).headline(),
|
||||||
|
"£0.17 worse off after travel")
|
||||||
|
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.05).headline(),
|
||||||
|
"£0.18 worse off after travel")
|
||||||
|
|
||||||
|
// 2.05 mi shows as "2.0 mi" at one decimal place — the same caption.
|
||||||
|
XCTAssertEqual(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 2.05, unit: .miles),
|
||||||
|
"2.0 mi extra return travel")
|
||||||
|
XCTAssertEqual(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 2.00, unit: .miles),
|
||||||
|
"2.0 mi extra return travel")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBriefCaseMFGHareAndHoundsIsWorseOff() throws {
|
||||||
|
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
|
||||||
|
|
||||||
|
XCTAssertEqual(result.fillLitres, 34.904014, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(result.grossSavingGBP, -0.349040, accuracy: 1e-6, "1p/litre dearer costs 34.90 L × 1p")
|
||||||
|
XCTAssertEqual(result.extraDistanceMiles, 2.8, accuracy: 1e-9)
|
||||||
|
XCTAssertEqual(result.extraDrivingCostGBP, 0.733618, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(result.netSavingGBP, -1.082658, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(result.status, .notWorthTheDrive)
|
||||||
|
XCTAssertEqual(result.headline(), "£1.08 worse off after travel")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBriefCasePGGNapoleonSamePumpPriceIsTravelCostOnly() throws {
|
||||||
|
let result = try briefResult(candidatePrice: 171.9, extraMiles: 2.8)
|
||||||
|
|
||||||
|
XCTAssertEqual(result.grossSavingGBP, 0, accuracy: 1e-9, "same pump price saves nothing at the pump")
|
||||||
|
XCTAssertEqual(result.extraDrivingCostGBP, 0.729375, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(result.netSavingGBP, -0.729375, accuracy: 1e-6,
|
||||||
|
"with no price difference the whole result is the extra driving")
|
||||||
|
XCTAssertEqual(result.status, .notWorthTheDrive)
|
||||||
|
XCTAssertEqual(result.headline(), "£0.73 worse off after travel")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 5/6. Classification, tolerance and rounding
|
||||||
|
|
||||||
|
func testSamePriceAndSameDistanceIsBreakEven() throws {
|
||||||
|
let result = try briefResult(candidatePrice: 171.9, extraMiles: 0)
|
||||||
|
XCTAssertEqual(result.grossSavingGBP, 0, accuracy: 1e-9)
|
||||||
|
XCTAssertEqual(result.extraDrivingCostGBP, 0, accuracy: 1e-9)
|
||||||
|
XCTAssertEqual(result.netSavingGBP, 0, accuracy: 1e-9)
|
||||||
|
XCTAssertEqual(result.status, .breakEven)
|
||||||
|
XCTAssertEqual(result.headline(), "Break even")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSmallSavingInsideTheToleranceIsBreakEvenNotPennies() throws {
|
||||||
|
// 171.9p vs 170.9p over a 1.23 mi delta: net +£0.0305.
|
||||||
|
let result = try briefResult(candidatePrice: 170.9, extraMiles: 1.23)
|
||||||
|
XCTAssertEqual(result.netSavingGBP, 0.030500, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(result.status, .breakEven, "±5p reads as break even rather than a meaningless £0.03")
|
||||||
|
XCTAssertEqual(result.headline(), "Break even")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBreakEvenToleranceDecidesTheClassificationAtTheBoundary() throws {
|
||||||
|
let net = -0.168911
|
||||||
|
// A tolerance wider than the loss → break even; narrower → worse off.
|
||||||
|
let lenient = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 30,
|
||||||
|
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
|
||||||
|
baselinePricePencePerLitre: 171.9,
|
||||||
|
candidatePricePencePerLitre: 170.9,
|
||||||
|
baselineJourneyMiles: baselineJourneyMiles,
|
||||||
|
candidateJourneyMiles: baselineJourneyMiles + 2.0,
|
||||||
|
breakEvenToleranceGBP: 0.17
|
||||||
|
)
|
||||||
|
XCTAssertEqual(lenient.netSavingGBP, net, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(lenient.status, .breakEven)
|
||||||
|
|
||||||
|
let strict = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 30,
|
||||||
|
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
|
||||||
|
baselinePricePencePerLitre: 171.9,
|
||||||
|
candidatePricePencePerLitre: 170.9,
|
||||||
|
baselineJourneyMiles: baselineJourneyMiles,
|
||||||
|
candidateJourneyMiles: baselineJourneyMiles + 2.0,
|
||||||
|
breakEvenToleranceGBP: 0.16
|
||||||
|
)
|
||||||
|
XCTAssertEqual(strict.status, .notWorthTheDrive)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMoneyIsRoundedOnlyForDisplay() throws {
|
||||||
|
// Full precision retained internally…
|
||||||
|
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
|
||||||
|
XCTAssertEqual(result.netSavingGBP, -1.082658, accuracy: 1e-6)
|
||||||
|
XCTAssertNotEqual(result.netSavingGBP, -1.08, "the stored value must not be pre-rounded")
|
||||||
|
// …and rounded to two places only when shown.
|
||||||
|
XCTAssertEqual(FuelSavingsFormat.money(1.082658), "£1.08")
|
||||||
|
XCTAssertEqual(FuelSavingsFormat.money(0.729375), "£0.73")
|
||||||
|
XCTAssertEqual(FuelSavingsFormat.money(0.1749), "£0.17")
|
||||||
|
XCTAssertEqual(FuelSavingsFormat.money(0.176), "£0.18")
|
||||||
|
XCTAssertEqual(FuelSavingsFormat.money(0), "£0.00")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSortingUsesTheUnroundedNetSoRoundedTiesStillOrderCorrectly() {
|
||||||
|
// Two rows that both DISPLAY "£0.18 worse off" but differ in the raw
|
||||||
|
// figure: the sort must use the raw value, never the shown one.
|
||||||
|
func ranked(_ id: String, net: Double) -> FuelSavingsRankedStation {
|
||||||
|
FuelSavingsRankedStation(
|
||||||
|
stationID: id,
|
||||||
|
result: .available(FuelSavingsResult(
|
||||||
|
fillLitres: 34.9,
|
||||||
|
grossSavingGBP: 0,
|
||||||
|
extraDrivingCostGBP: abs(net),
|
||||||
|
extraDistanceMiles: 2.0,
|
||||||
|
netSavingGBP: net,
|
||||||
|
status: .notWorthTheDrive
|
||||||
|
)),
|
||||||
|
distanceMiles: 2.8,
|
||||||
|
pricePencePerLitre: 171.9
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let worse = ranked("worse", net: -0.1751)
|
||||||
|
let lessWorse = ranked("less-worse", net: -0.1749)
|
||||||
|
XCTAssertEqual(FuelSavingsFormat.money(abs(worse.result.result!.netSavingGBP)), "£0.18")
|
||||||
|
XCTAssertEqual(FuelSavingsFormat.money(abs(lessWorse.result.result!.netSavingGBP)), "£0.17")
|
||||||
|
|
||||||
|
let sorted = FuelSavingsCalculator.sortedForWorthTheDrive([worse, lessWorse])
|
||||||
|
XCTAssertEqual(sorted.first?.stationID, "less-worse",
|
||||||
|
"the smaller loss must rank first even when the money rounds to the same figure")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 3. Distances and travel cost
|
||||||
|
|
||||||
|
func testCheaperFuelFurtherAwayCanStillBeWorthTheDrive() throws {
|
||||||
|
// 145.9p nearby vs 138.9p five miles further out, £40 fill, 45 MPG.
|
||||||
|
let result = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 45,
|
||||||
|
amount: FuelSavingsFillAmount(value: 40, unit: .pounds),
|
||||||
|
baselinePricePencePerLitre: 145.9,
|
||||||
|
candidatePricePencePerLitre: 138.9,
|
||||||
|
baselineJourneyMiles: 2.0,
|
||||||
|
candidateJourneyMiles: 7.0
|
||||||
|
)
|
||||||
|
XCTAssertEqual(result.fillLitres, 27.416038, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(result.grossSavingGBP, 1.919123, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(result.extraDrivingCostGBP, 0.701613, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(result.netSavingGBP, 1.217509, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(result.status, .worthTheDrive)
|
||||||
|
XCTAssertEqual(result.headline(), "£1.22 better off after travel")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNearerCandidateIsNeverChargedExtraTravel() throws {
|
||||||
|
// The baseline is the nearest priced station, so this should not happen
|
||||||
|
// in the app — but the clamp must hold if it ever does.
|
||||||
|
let result = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 30,
|
||||||
|
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
|
||||||
|
baselinePricePencePerLitre: 171.9,
|
||||||
|
candidatePricePencePerLitre: 169.9,
|
||||||
|
baselineJourneyMiles: 2.0,
|
||||||
|
candidateJourneyMiles: 1.0
|
||||||
|
)
|
||||||
|
XCTAssertEqual(result.extraDistanceMiles, 0, "a shorter journey is never a negative extra distance")
|
||||||
|
XCTAssertEqual(result.extraDrivingCostGBP, 0, accuracy: 1e-12)
|
||||||
|
XCTAssertEqual(result.netSavingGBP, result.grossSavingGBP, accuracy: 1e-12)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDifferentMPGValuesScaleOnlyTheTravelCost() throws {
|
||||||
|
let sixty = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 60,
|
||||||
|
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
|
||||||
|
baselinePricePencePerLitre: 171.9,
|
||||||
|
candidatePricePencePerLitre: 172.9,
|
||||||
|
baselineJourneyMiles: 0.8,
|
||||||
|
candidateJourneyMiles: 3.6
|
||||||
|
)
|
||||||
|
XCTAssertEqual(sixty.grossSavingGBP, -0.349040, accuracy: 1e-6, "MPG must not touch the pump saving")
|
||||||
|
XCTAssertEqual(sixty.extraDrivingCostGBP, 0.366809, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(sixty.netSavingGBP, -0.715849, accuracy: 1e-6)
|
||||||
|
|
||||||
|
let fortyFive = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 45,
|
||||||
|
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
|
||||||
|
baselinePricePencePerLitre: 171.9,
|
||||||
|
candidatePricePencePerLitre: 172.9,
|
||||||
|
baselineJourneyMiles: 0.8,
|
||||||
|
candidateJourneyMiles: 3.6
|
||||||
|
)
|
||||||
|
XCTAssertEqual(fortyFive.extraDrivingCostGBP, 0.489078, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(fortyFive.netSavingGBP, -0.838119, accuracy: 1e-6)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDifferentFillBudgetsScaleOnlyThePumpSaving() throws {
|
||||||
|
let forty = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 30,
|
||||||
|
amount: FuelSavingsFillAmount(value: 40, unit: .pounds),
|
||||||
|
baselinePricePencePerLitre: 171.9,
|
||||||
|
candidatePricePencePerLitre: 172.9,
|
||||||
|
baselineJourneyMiles: 0.8,
|
||||||
|
candidateJourneyMiles: 3.6
|
||||||
|
)
|
||||||
|
XCTAssertEqual(forty.fillLitres, 23.269343, accuracy: 1e-6, "£40 buys 23.27 L at 171.9p")
|
||||||
|
XCTAssertEqual(forty.grossSavingGBP, -0.232693, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(forty.extraDrivingCostGBP, 0.733618, accuracy: 1e-6, "the journey cost is budget-independent")
|
||||||
|
XCTAssertEqual(forty.netSavingGBP, -0.966311, accuracy: 1e-6)
|
||||||
|
|
||||||
|
let ninety = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 30,
|
||||||
|
amount: FuelSavingsFillAmount(value: 90, unit: .pounds),
|
||||||
|
baselinePricePencePerLitre: 171.9,
|
||||||
|
candidatePricePencePerLitre: 172.9,
|
||||||
|
baselineJourneyMiles: 0.8,
|
||||||
|
candidateJourneyMiles: 3.6
|
||||||
|
)
|
||||||
|
XCTAssertEqual(ninety.fillLitres, 52.356021, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(ninety.grossSavingGBP, -0.523560, accuracy: 1e-6)
|
||||||
|
XCTAssertEqual(ninety.netSavingGBP, -1.257178, accuracy: 1e-6)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFillUpInLitresIsFixedRegardlessOfPrice() throws {
|
||||||
|
// 40 L is 40 L: no baseline price is involved in the quantity.
|
||||||
|
let result = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 30,
|
||||||
|
amount: FuelSavingsFillAmount(value: 40, unit: .litres),
|
||||||
|
baselinePricePencePerLitre: 171.9,
|
||||||
|
candidatePricePencePerLitre: 165.9,
|
||||||
|
baselineJourneyMiles: 0.8,
|
||||||
|
candidateJourneyMiles: 3.6
|
||||||
|
)
|
||||||
|
XCTAssertEqual(result.fillLitres, 40, accuracy: 1e-9)
|
||||||
|
XCTAssertEqual(result.grossSavingGBP, 40 * 0.06, accuracy: 1e-9)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 7. Failures never invent a saving
|
||||||
|
|
||||||
|
func testUnavailableRouteProducesNoSavingAtAll() {
|
||||||
|
let baseline = station("baseline", price: 171.9)
|
||||||
|
let candidate = station("candidate", price: 170.9)
|
||||||
|
let settings = FuelSavingsSettings(
|
||||||
|
enabled: true,
|
||||||
|
mpg: 30,
|
||||||
|
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds)
|
||||||
|
)
|
||||||
|
|
||||||
|
let noRoute = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
|
||||||
|
journey: nil,
|
||||||
|
baselineJourney: journey(0.8)
|
||||||
|
)
|
||||||
|
XCTAssertEqual(noRoute, .unavailable("Distance unavailable"))
|
||||||
|
|
||||||
|
let noBaselineRoute = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
|
||||||
|
journey: journey(2.8),
|
||||||
|
baselineJourney: nil
|
||||||
|
)
|
||||||
|
XCTAssertEqual(noBaselineRoute, .unavailable("No comparison station"))
|
||||||
|
|
||||||
|
// With both legs present it produces the saving — no silent zero.
|
||||||
|
let both = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
|
||||||
|
journey: journey(2.8),
|
||||||
|
baselineJourney: journey(0.8)
|
||||||
|
)
|
||||||
|
XCTAssertEqual(both?.result?.netSavingGBP ?? 0, -0.168911, accuracy: 1e-6)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMixedRouteBasisIsReportedAsAnEstimate() {
|
||||||
|
let baseline = station("baseline", price: 171.9)
|
||||||
|
let candidate = station("candidate", price: 170.9)
|
||||||
|
let settings = FuelSavingsSettings(
|
||||||
|
enabled: true,
|
||||||
|
mpg: 30,
|
||||||
|
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds)
|
||||||
|
)
|
||||||
|
let mixed = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
|
||||||
|
journey: journey(2.8, basis: .straightLine),
|
||||||
|
baselineJourney: journey(0.8, basis: .roadRoute)
|
||||||
|
)
|
||||||
|
XCTAssertEqual(mixed?.result?.distanceBasis, .straightLine,
|
||||||
|
"one unmeasured leg makes the whole estimate an estimate")
|
||||||
|
XCTAssertEqual(mixed?.result?.journeyConfidence, .estimated)
|
||||||
|
XCTAssertEqual(mixed?.result?.headline(compact: true), "Approx. £0.17 worse off after travel")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBaselineStationIsLabelledClosestStationNotBreakEven() {
|
||||||
|
let baseline = station("baseline", price: 171.9)
|
||||||
|
let settings = FuelSavingsSettings(
|
||||||
|
enabled: true,
|
||||||
|
mpg: 30,
|
||||||
|
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds)
|
||||||
|
)
|
||||||
|
let result = FuelSavingsEstimator.availability(
|
||||||
|
station: baseline,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
|
||||||
|
journey: journey(0.8),
|
||||||
|
baselineJourney: journey(0.8)
|
||||||
|
)?.result
|
||||||
|
XCTAssertEqual(result?.status, .baseline)
|
||||||
|
XCTAssertEqual(result?.netSavingGBP, 0)
|
||||||
|
XCTAssertEqual(result?.headline(), "Closest station")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,655 @@
|
|||||||
|
import XCTest
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
|
@testable import FuelBoardShared
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Ranking rules for the Worth the Drive sort.
|
||||||
|
///
|
||||||
|
/// Regression: the sort used to rank only a shortlist of the nearest stations
|
||||||
|
/// whose ROAD ROUTES had been measured, and everything else was listed as
|
||||||
|
/// unavailable. Cheaper stations a few miles further out — the exact stations
|
||||||
|
/// the sort exists to surface — were dropped from the top of the list, and
|
||||||
|
/// stations on later pages showed no savings at all.
|
||||||
|
final class FuelSavingsEstimateTests: XCTestCase {
|
||||||
|
private let settings = FuelSavingsSettings(
|
||||||
|
enabled: true,
|
||||||
|
mpg: 45,
|
||||||
|
preferredAmount: FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||||
|
)
|
||||||
|
private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||||
|
|
||||||
|
private func station(_ id: String,
|
||||||
|
price: Double?,
|
||||||
|
lat: Double = 51.5,
|
||||||
|
lng: Double = -0.1) -> FuelStation {
|
||||||
|
FuelStation(
|
||||||
|
id: id,
|
||||||
|
name: id,
|
||||||
|
brand: "X",
|
||||||
|
address: "",
|
||||||
|
postcode: "",
|
||||||
|
lat: lat,
|
||||||
|
lng: lng,
|
||||||
|
prices: price.map { [FuelType.e10: $0] } ?? [:],
|
||||||
|
priceUpdated: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Straight-line out-and-back estimate, as the app builds it when no road
|
||||||
|
/// route has been measured yet.
|
||||||
|
private func straightLine(_ miles: Double) -> FuelSavingsJourney {
|
||||||
|
FuelSavingsJourney(miles: miles, basis: .straightLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCheaperStationThreePointFiveMilesAwayIsRankedFirstWithASaving() {
|
||||||
|
// Nearest station: 1 mile away at 145.9p. The station the user can see
|
||||||
|
// in the Cheapest tab: 3.5 miles away at 138.9p.
|
||||||
|
let baseline = station("baseline", price: 145.9)
|
||||||
|
let candidate = station("cheaper-3.5", price: 138.9, lat: 51.55)
|
||||||
|
let filler = (0..<14).map { station("filler-\($0)", price: 150 + Double($0)) }
|
||||||
|
let pool = [baseline, candidate] + filler
|
||||||
|
|
||||||
|
let ranked = FuelSavingsEstimator.ranked(
|
||||||
|
pool,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journeyMiles: { s in
|
||||||
|
switch s.id {
|
||||||
|
case "baseline": return self.straightLine(2.0)
|
||||||
|
case "cheaper-3.5": return self.straightLine(7.0)
|
||||||
|
default: return self.straightLine(20.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(ranked.count, pool.count)
|
||||||
|
XCTAssertEqual(ranked.first?.id, "cheaper-3.5",
|
||||||
|
"a cheaper station 3.5 miles away must rank above the nearest station")
|
||||||
|
|
||||||
|
let result = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journey: straightLine(7.0),
|
||||||
|
baselineJourney: straightLine(2.0)
|
||||||
|
)?.result
|
||||||
|
|
||||||
|
XCTAssertNotNil(result, "the station must show a saving, not an unavailable state")
|
||||||
|
XCTAssertGreaterThan(result?.netSavingGBP ?? -1, 0)
|
||||||
|
XCTAssertEqual(result?.status, .worthTheDrive)
|
||||||
|
XCTAssertEqual(result?.distanceBasis, .straightLine,
|
||||||
|
"unmeasured routes are reported as straight-line estimates")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEveryPricedStationInThePoolIsEstimatedNotJustAShortlist() {
|
||||||
|
// 40 stations, but only the first one has a measured road route — the
|
||||||
|
// old implementation capped the whole ranking to 12 shortlisted
|
||||||
|
// stations and returned "Outside route shortlist" for the rest.
|
||||||
|
let baseline = station("baseline", price: 145.9)
|
||||||
|
let pool = [baseline] + (0..<39).map { station("s\($0)", price: 135 + Double($0)) }
|
||||||
|
|
||||||
|
let ranked = FuelSavingsEstimator.ranked(
|
||||||
|
pool,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journeyMiles: { s in
|
||||||
|
s.id == "baseline"
|
||||||
|
? FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||||||
|
: self.straightLine(2.0 + (Double(s.id.dropFirst()) ?? 6))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(ranked.count, pool.count)
|
||||||
|
|
||||||
|
for station in pool where station.id != "baseline" {
|
||||||
|
let availability = FuelSavingsEstimator.availability(
|
||||||
|
station: station,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journey: straightLine(10.0),
|
||||||
|
baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||||||
|
)
|
||||||
|
XCTAssertNotNil(availability?.result,
|
||||||
|
"\(station.id) should carry an estimated saving, not an unavailable state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMeasuredRoadRoutesAreReportedAsRoadRouteBasis() {
|
||||||
|
let baseline = station("baseline", price: 145.9)
|
||||||
|
let candidate = station("candidate", price: 138.9)
|
||||||
|
|
||||||
|
let result = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journey: FuelSavingsJourney(miles: 7.0, basis: .roadRoute),
|
||||||
|
baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||||||
|
)?.result
|
||||||
|
|
||||||
|
XCTAssertEqual(result?.distanceBasis, .roadRoute)
|
||||||
|
XCTAssertFalse(FuelSavingsDistanceBasis.roadRoute.isEstimate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMixedBasisIsReportedAsAnEstimate() {
|
||||||
|
let baseline = station("baseline", price: 145.9)
|
||||||
|
let candidate = station("candidate", price: 138.9)
|
||||||
|
|
||||||
|
// Baseline measured, candidate not: the comparison is only as good as
|
||||||
|
// its weakest leg, so it must be labelled an estimate.
|
||||||
|
let result = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journey: straightLine(7.0),
|
||||||
|
baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||||||
|
)?.result
|
||||||
|
|
||||||
|
XCTAssertEqual(result?.distanceBasis, .straightLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBaselineStationUsesTheSameJourneyValuesAsSavings() {
|
||||||
|
let geographicallyNearest = station("geographically-nearest", price: 145.9)
|
||||||
|
let roadNearest = station("road-nearest", price: 146.9)
|
||||||
|
let noPrice = station("no-price", price: nil)
|
||||||
|
|
||||||
|
let baseline = FuelSavingsEstimator.baselineStation(
|
||||||
|
[geographicallyNearest, roadNearest, noPrice],
|
||||||
|
fuel: .e10,
|
||||||
|
journeyMiles: { station in
|
||||||
|
switch station.id {
|
||||||
|
case "geographically-nearest": return FuelSavingsJourney(miles: 8.0, basis: .roadRoute)
|
||||||
|
case "road-nearest": return FuelSavingsJourney(miles: 4.0, basis: .roadRoute)
|
||||||
|
case "no-price": return FuelSavingsJourney(miles: 1.0, basis: .roadRoute)
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(baseline?.id, "road-nearest",
|
||||||
|
"the Best Value baseline must be the shortest usable journey, not a separate straight-line/display-distance choice")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBaselineStationFallsBackThenChangesWhenRoadRoutesArrive() {
|
||||||
|
let stationA = station("a", price: 145.9)
|
||||||
|
let stationB = station("b", price: 146.9)
|
||||||
|
|
||||||
|
let noRoadRoutes = FuelSavingsEstimator.baselineStation(
|
||||||
|
[stationA, stationB],
|
||||||
|
fuel: .e10,
|
||||||
|
journeyMiles: { station in
|
||||||
|
switch station.id {
|
||||||
|
case "a": return FuelSavingsJourney(miles: 2.0, basis: .straightLine)
|
||||||
|
case "b": return FuelSavingsJourney(miles: 3.0, basis: .straightLine)
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
XCTAssertEqual(noRoadRoutes?.id, "a")
|
||||||
|
|
||||||
|
let partialRoadRoutes = FuelSavingsEstimator.baselineStation(
|
||||||
|
[stationA, stationB],
|
||||||
|
fuel: .e10,
|
||||||
|
journeyMiles: { station in
|
||||||
|
switch station.id {
|
||||||
|
case "a": return FuelSavingsJourney(miles: 5.0, basis: .roadRoute)
|
||||||
|
case "b": return FuelSavingsJourney(miles: 3.0, basis: .straightLine)
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
XCTAssertEqual(partialRoadRoutes?.id, "b",
|
||||||
|
"baseline must be recalculated from the latest journey values when routes arrive")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRankingToleratesDuplicateStationIDs() {
|
||||||
|
let baseline = station("baseline", price: 145.9)
|
||||||
|
let duplicateA = station("duplicate", price: 138.9, lat: 51.55)
|
||||||
|
let duplicateB = station("duplicate", price: 139.9, lat: 51.56)
|
||||||
|
let pool = [baseline, duplicateA, duplicateB]
|
||||||
|
|
||||||
|
let ranked = FuelSavingsEstimator.ranked(
|
||||||
|
pool,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journeyMiles: { s in
|
||||||
|
s.id == "baseline" ? self.straightLine(2.0) : self.straightLine(7.0)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(ranked.count, pool.count)
|
||||||
|
XCTAssertEqual(ranked.filter { $0.id == "duplicate" }.count, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStationWithoutAPriceIsUnavailable() {
|
||||||
|
let baseline = station("baseline", price: 145.9)
|
||||||
|
let noPrice = station("no-price", price: nil)
|
||||||
|
|
||||||
|
let availability = FuelSavingsEstimator.availability(
|
||||||
|
station: noPrice,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journey: straightLine(4.0),
|
||||||
|
baselineJourney: straightLine(2.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(availability, .unavailable("No price"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFeatureOffProducesNoSavingsRow() {
|
||||||
|
let baseline = station("baseline", price: 145.9)
|
||||||
|
let candidate = station("candidate", price: 138.9)
|
||||||
|
|
||||||
|
let availability = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: .defaults,
|
||||||
|
amount: amount,
|
||||||
|
journey: straightLine(4.0),
|
||||||
|
baselineJourney: straightLine(2.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertNil(availability)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Required wording and baseline labelling
|
||||||
|
|
||||||
|
private var briefSettings: FuelSavingsSettings {
|
||||||
|
FuelSavingsSettings(enabled: true,
|
||||||
|
mpg: 30,
|
||||||
|
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var briefAmount: FuelSavingsFillAmount {
|
||||||
|
FuelSavingsFillAmount(value: 60, unit: .pounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scenario from the brief: 30 MPG, £60 fill, baseline Rontec Shelf
|
||||||
|
/// (171.9p, 0.4 mi), candidate Shell Whetley Hill (167.9p, 3.8 mi).
|
||||||
|
///
|
||||||
|
/// Whetley Hill is 4p/litre cheaper, yet it is WORSE OFF — and that is
|
||||||
|
/// correct, not a calculation bug: £60 buys 34.90 L at 171.9p, so the
|
||||||
|
/// cheaper pump saves 34.90 × 4p = £1.40, while the extra 6.8 return miles
|
||||||
|
/// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.33.
|
||||||
|
func testCheaperStationFurtherAwayIsWorseOffAfterTravel() {
|
||||||
|
let baseline = station("rontec-shelf", price: 171.9)
|
||||||
|
let candidate = station("shell-whetley-hill", price: 167.9)
|
||||||
|
|
||||||
|
let availability = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: briefSettings,
|
||||||
|
amount: briefAmount,
|
||||||
|
journey: straightLine(7.6), // 3.8 mi out and back
|
||||||
|
baselineJourney: straightLine(0.8) // 0.4 mi out and back
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let result = availability?.result else {
|
||||||
|
return XCTFail("expected an estimate, got \(String(describing: availability))")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(result.fillLitres, 34.904, accuracy: 0.005,
|
||||||
|
"the £60 budget buys litres at the BASELINE pump price")
|
||||||
|
XCTAssertEqual(result.grossSavingGBP, 1.396, accuracy: 0.005)
|
||||||
|
XCTAssertEqual(result.extraDistanceMiles, 6.8, accuracy: 0.001,
|
||||||
|
"only the return-trip DELTA counts — the shared journey is never counted twice")
|
||||||
|
XCTAssertEqual(result.extraDrivingCostGBP, 1.730, accuracy: 0.005)
|
||||||
|
XCTAssertEqual(result.netSavingGBP, -0.334, accuracy: 0.005)
|
||||||
|
XCTAssertEqual(result.status, .notWorthTheDrive)
|
||||||
|
XCTAssertEqual(result.journeyConfidence, .estimated)
|
||||||
|
XCTAssertEqual(result.headline(compact: true), "Approx. £0.33 worse off after travel")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMeasuredRoadRoutesDropTheEstimateMarker() {
|
||||||
|
let baseline = station("baseline", price: 171.9)
|
||||||
|
let candidate = station("candidate", price: 167.9)
|
||||||
|
|
||||||
|
let availability = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: briefSettings,
|
||||||
|
amount: briefAmount,
|
||||||
|
journey: FuelSavingsJourney(miles: 7.6, basis: .roadRoute),
|
||||||
|
baselineJourney: FuelSavingsJourney(miles: 0.8, basis: .roadRoute)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(availability?.result?.distanceBasis, .roadRoute)
|
||||||
|
XCTAssertEqual(availability?.result?.journeyConfidence, .confirmed)
|
||||||
|
XCTAssertEqual(availability?.result?.headline(compact: true), "£0.33 worse off after travel")
|
||||||
|
XCTAssertEqual(availability?.result?.headline(), "£0.33 worse off after travel")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBaselineRowIsLabelledClosestStation() {
|
||||||
|
let baseline = station("nearest", price: 171.9)
|
||||||
|
|
||||||
|
let availability = FuelSavingsEstimator.availability(
|
||||||
|
station: baseline,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: briefSettings,
|
||||||
|
amount: briefAmount,
|
||||||
|
journey: straightLine(0.8),
|
||||||
|
baselineJourney: straightLine(0.8)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(availability?.result?.status, .baseline)
|
||||||
|
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest station")
|
||||||
|
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest station")
|
||||||
|
XCTAssertEqual(availability?.result?.netSavingGBP, 0)
|
||||||
|
XCTAssertEqual(availability?.result?.extraDistanceMiles, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPositiveResultHeadlineSaysSaveAfterTravel() {
|
||||||
|
// 4p cheaper AND almost the same distance, so the saving survives travel.
|
||||||
|
let baseline = station("baseline", price: 160.0)
|
||||||
|
let candidate = station("candidate", price: 150.0)
|
||||||
|
|
||||||
|
let availability = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: briefSettings,
|
||||||
|
amount: briefAmount,
|
||||||
|
journey: straightLine(6.4),
|
||||||
|
baselineJourney: straightLine(6.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let result = availability?.result else {
|
||||||
|
return XCTFail("expected an estimate, got \(String(describing: availability))")
|
||||||
|
}
|
||||||
|
XCTAssertEqual(result.status, .worthTheDrive)
|
||||||
|
// £60 buys 37.5 L at 160p; 10p/litre cheaper saves £3.75; 0.4 extra
|
||||||
|
// return miles cost ~£0.09. Net ≈ £3.66.
|
||||||
|
XCTAssertEqual(result.netSavingGBP, 3.659, accuracy: 0.005)
|
||||||
|
XCTAssertEqual(result.headline(compact: true), "Approx. £3.66 better off after travel")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNeutralResultHeadlineIsBreakEven() {
|
||||||
|
// 0.1p cheaper over the same distance: inside the break-even tolerance.
|
||||||
|
let baseline = station("baseline", price: 150.0)
|
||||||
|
let candidate = station("candidate", price: 149.9)
|
||||||
|
|
||||||
|
let availability = FuelSavingsEstimator.availability(
|
||||||
|
station: candidate,
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: briefSettings,
|
||||||
|
amount: briefAmount,
|
||||||
|
journey: straightLine(6.0),
|
||||||
|
baselineJourney: straightLine(6.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(availability?.result?.status, .breakEven)
|
||||||
|
XCTAssertEqual(availability?.result?.headline(compact: true), "Break even")
|
||||||
|
XCTAssertEqual(availability?.result?.headline(), "Break even")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exact strings the brief asks for.
|
||||||
|
func testHeadlineWordingMatchesTheBrief() {
|
||||||
|
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest station")
|
||||||
|
XCTAssertEqual(FuelSavingsResult.breakEvenHeadline, "Break even")
|
||||||
|
|
||||||
|
let saving = FuelSavingsResult(fillLitres: 34.9,
|
||||||
|
grossSavingGBP: 1.40,
|
||||||
|
extraDrivingCostGBP: 0.88,
|
||||||
|
extraDistanceMiles: 3.5,
|
||||||
|
netSavingGBP: 0.52,
|
||||||
|
status: .worthTheDrive,
|
||||||
|
distanceBasis: .roadRoute)
|
||||||
|
XCTAssertEqual(saving.headline(), "£0.52 better off after travel")
|
||||||
|
|
||||||
|
let loss = FuelSavingsResult(fillLitres: 34.9,
|
||||||
|
grossSavingGBP: 1.40,
|
||||||
|
extraDrivingCostGBP: 1.71,
|
||||||
|
extraDistanceMiles: 6.8,
|
||||||
|
netSavingGBP: -0.31,
|
||||||
|
status: .notWorthTheDrive,
|
||||||
|
distanceBasis: .roadRoute)
|
||||||
|
XCTAssertEqual(loss.headline(), "£0.31 worse off after travel")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sort must order by NET saving, not pump price: a pricier station can
|
||||||
|
/// win when the cheaper one is far enough away.
|
||||||
|
func testSortingRanksByNetSavingNotPumpPrice() {
|
||||||
|
let baseline = station("baseline", price: 171.9)
|
||||||
|
let cheapButFar = station("cheap-but-far", price: 167.9)
|
||||||
|
let pricierButNear = station("pricier-but-near", price: 169.9)
|
||||||
|
|
||||||
|
let ranked = FuelSavingsEstimator.ranked(
|
||||||
|
[baseline, cheapButFar, pricierButNear],
|
||||||
|
fuel: .e10,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: briefSettings,
|
||||||
|
amount: briefAmount,
|
||||||
|
journeyMiles: { s in
|
||||||
|
switch s.id {
|
||||||
|
case "baseline": return self.straightLine(0.8)
|
||||||
|
case "cheap-but-far": return self.straightLine(7.6)
|
||||||
|
default: return self.straightLine(1.2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(ranked.first?.id, "pricier-but-near",
|
||||||
|
"net saving decides the order, not the pump price")
|
||||||
|
XCTAssertEqual(ranked.last?.id, "cheap-but-far")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Row copy
|
||||||
|
|
||||||
|
/// The row's extra-driving caption must follow the DISPLAY unit: the stored
|
||||||
|
/// figure is always miles, and converting by the current unit instead would
|
||||||
|
/// leave a km user's figure unlabelled and 1.6× too large.
|
||||||
|
func testExtraTravelLabelFollowsTheDisplayUnit() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 19.9, unit: .miles),
|
||||||
|
"19.9 mi extra return travel"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 19.9, unit: .kilometers),
|
||||||
|
"32.0 km extra return travel"
|
||||||
|
)
|
||||||
|
XCTAssertNil(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 0, unit: .miles),
|
||||||
|
"the baseline row has no extra travel to report")
|
||||||
|
// The same conversion backs the detail sheet's extra-distance figure,
|
||||||
|
// which used to be hardcoded miles ("%.1f miles") for km users too.
|
||||||
|
XCTAssertEqual(FuelSavingsFormat.distance(19.9, unit: .miles), "19.9 mi")
|
||||||
|
XCTAssertEqual(FuelSavingsFormat.distance(19.9, unit: .kilometers), "32.0 km")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The baseline capsule has to say what it IS: it previously repeated the
|
||||||
|
/// status word above it ("Closest station" twice) and never explained that
|
||||||
|
/// the savings are measured against it.
|
||||||
|
func testCapsuleHeadlineMarksTheBaselineAndKeepsTheEstimateMarker() {
|
||||||
|
let baseline = FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: 0,
|
||||||
|
extraDrivingCostGBP: 0,
|
||||||
|
extraDistanceMiles: 0,
|
||||||
|
netSavingGBP: 0,
|
||||||
|
status: .baseline
|
||||||
|
)
|
||||||
|
XCTAssertEqual(baseline.capsuleHeadline(), "Closest station · your baseline")
|
||||||
|
|
||||||
|
let saving = FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: 2.40,
|
||||||
|
extraDrivingCostGBP: 1.40,
|
||||||
|
extraDistanceMiles: 5.2,
|
||||||
|
netSavingGBP: 1.00,
|
||||||
|
status: .worthTheDrive,
|
||||||
|
distanceBasis: .straightLine
|
||||||
|
)
|
||||||
|
XCTAssertEqual(saving.capsuleHeadline(), "Approx. £1.00 better off after travel")
|
||||||
|
|
||||||
|
let worse = FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: 1.40,
|
||||||
|
extraDrivingCostGBP: 1.70,
|
||||||
|
extraDistanceMiles: 6.7,
|
||||||
|
netSavingGBP: -0.31,
|
||||||
|
status: .notWorthTheDrive
|
||||||
|
)
|
||||||
|
XCTAssertEqual(worse.capsuleHeadline(), "£0.31 worse off after travel")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Estimate wording (Approx. labels)
|
||||||
|
|
||||||
|
/// The estimate marker is the WORD "Approx.", never a "~", and it is applied to
|
||||||
|
/// both directions of the money result.
|
||||||
|
func testEstimatedHeadlinesUseTheWordApproxForBothDirections() {
|
||||||
|
let saving = FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: 4.00,
|
||||||
|
extraDrivingCostGBP: 2.75,
|
||||||
|
extraDistanceMiles: 6.4,
|
||||||
|
netSavingGBP: 1.25,
|
||||||
|
status: .worthTheDrive,
|
||||||
|
distanceBasis: .straightLine
|
||||||
|
)
|
||||||
|
XCTAssertEqual(saving.headline(), "Approx. £1.25 better off after travel")
|
||||||
|
|
||||||
|
let loss = FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: 1.40,
|
||||||
|
extraDrivingCostGBP: 2.17,
|
||||||
|
extraDistanceMiles: 6.4,
|
||||||
|
netSavingGBP: -0.77,
|
||||||
|
status: .notWorthTheDrive,
|
||||||
|
distanceBasis: .straightLine
|
||||||
|
)
|
||||||
|
XCTAssertEqual(loss.headline(), "Approx. £0.77 worse off after travel")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A confirmed road-route comparison must never carry the marker — in either
|
||||||
|
/// direction, and not as a stray tilde either.
|
||||||
|
func testConfirmedHeadlinesCarryNoApproxMarker() {
|
||||||
|
let saving = FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: 4.00,
|
||||||
|
extraDrivingCostGBP: 2.75,
|
||||||
|
extraDistanceMiles: 6.4,
|
||||||
|
netSavingGBP: 1.25,
|
||||||
|
status: .worthTheDrive,
|
||||||
|
distanceBasis: .roadRoute,
|
||||||
|
journeyConfidence: .confirmed
|
||||||
|
)
|
||||||
|
XCTAssertEqual(saving.headline(), "£1.25 better off after travel")
|
||||||
|
|
||||||
|
let loss = FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: 1.40,
|
||||||
|
extraDrivingCostGBP: 2.17,
|
||||||
|
extraDistanceMiles: 6.4,
|
||||||
|
netSavingGBP: -0.77,
|
||||||
|
status: .notWorthTheDrive,
|
||||||
|
distanceBasis: .roadRoute,
|
||||||
|
journeyConfidence: .confirmed
|
||||||
|
)
|
||||||
|
XCTAssertEqual(loss.headline(), "£0.77 worse off after travel")
|
||||||
|
|
||||||
|
for headline in [saving.headline(), loss.headline(), saving.capsuleHeadline()] {
|
||||||
|
XCTAssertFalse(headline.contains("Approx"), "\"\(headline)\" must not claim to be an estimate")
|
||||||
|
XCTAssertFalse(headline.contains("~"), "\"\(headline)\" must use a word, not a symbol")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The extra-travel caption names the RETURN journey in both states. The
|
||||||
|
/// estimated caption repeats "Approx." deliberately: it describes a different
|
||||||
|
/// value from the verdict above it.
|
||||||
|
func testExtraTravelNamesTheReturnJourney() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 6.4, unit: .miles),
|
||||||
|
"6.4 mi extra return travel"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated),
|
||||||
|
"Approx. 6.4 mi extra return travel"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VoiceOver gets words, not abbreviations: "Approx." → "Approximately" and
|
||||||
|
/// "mi"/"km" → "miles"/"kilometres".
|
||||||
|
func testSpokenLabelsSpellOutTheAbbreviations() {
|
||||||
|
let estimated = FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: 1.40,
|
||||||
|
extraDrivingCostGBP: 2.17,
|
||||||
|
extraDistanceMiles: 6.4,
|
||||||
|
netSavingGBP: -0.77,
|
||||||
|
status: .notWorthTheDrive,
|
||||||
|
distanceBasis: .straightLine
|
||||||
|
)
|
||||||
|
XCTAssertEqual(estimated.spokenHeadline(), "Approximately £0.77 worse off after travel")
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
FuelSavingsFormat.extraTravelSpokenLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated),
|
||||||
|
"Approximately 6.4 miles extra return travel"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
FuelSavingsFormat.extraTravelSpokenLabel(extraDistanceMiles: 19.9, unit: .kilometers),
|
||||||
|
"32.0 kilometres extra return travel"
|
||||||
|
)
|
||||||
|
|
||||||
|
let confirmed = FuelSavingsResult(
|
||||||
|
fillLitres: 30,
|
||||||
|
grossSavingGBP: 1.40,
|
||||||
|
extraDrivingCostGBP: 2.17,
|
||||||
|
extraDistanceMiles: 6.4,
|
||||||
|
netSavingGBP: -0.77,
|
||||||
|
status: .notWorthTheDrive,
|
||||||
|
distanceBasis: .roadRoute,
|
||||||
|
journeyConfidence: .confirmed
|
||||||
|
)
|
||||||
|
XCTAssertEqual(confirmed.spokenHeadline(), confirmed.headline(),
|
||||||
|
"a confirmed comparison has no abbreviation to spell out")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Slider bounds (Settings and the in-list control share these)
|
||||||
|
|
||||||
|
/// Both fill-up controls are slider-only now, so every position the slider
|
||||||
|
/// can reach must be a usable amount — otherwise the control could sit on a
|
||||||
|
/// figure the calculator rejects.
|
||||||
|
func testFillAmountSliderBoundsAreAlwaysValidAmounts() {
|
||||||
|
for unit in FuelSavingsFillUnit.allCases {
|
||||||
|
let range = FuelSavingsCalculator.fillAmountSliderRange(for: unit)
|
||||||
|
let step = FuelSavingsCalculator.fillAmountSliderStep(for: unit)
|
||||||
|
XCTAssertLessThan(range.lowerBound, range.upperBound, "\(unit) range must be usable")
|
||||||
|
XCTAssertGreaterThan(step, 0, "\(unit) step must advance")
|
||||||
|
XCTAssertGreaterThan(range.lowerBound, 0, "\(unit) must never offer a zero fill")
|
||||||
|
XCTAssertLessThanOrEqual(range.upperBound,
|
||||||
|
FuelSavingsCalculator.maximumFillAmount(for: unit),
|
||||||
|
"\(unit) slider must stay inside the valid ceiling")
|
||||||
|
for position in [range.lowerBound, range.upperBound, (range.lowerBound + range.upperBound) / 2] {
|
||||||
|
XCTAssertTrue(FuelSavingsFillAmount(value: position, unit: unit).isValid,
|
||||||
|
"\(position) \(unit) must be a valid amount")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMPGSliderBoundsAreInsideTheValidMPGRange() {
|
||||||
|
let range = FuelSavingsCalculator.mpgSliderRange
|
||||||
|
XCTAssertLessThan(range.lowerBound, range.upperBound)
|
||||||
|
XCTAssertGreaterThan(FuelSavingsCalculator.mpgSliderStep, 0)
|
||||||
|
XCTAssertTrue(FuelSavingsCalculator.isValidMPG(range.lowerBound))
|
||||||
|
XCTAssertTrue(FuelSavingsCalculator.isValidMPG(range.upperBound))
|
||||||
|
XCTAssertTrue(FuelSavingsCalculator.isValidMPG(45), "a typical UK figure must be reachable")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import XCTest
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
|
@testable import FuelBoardShared
|
||||||
|
#endif
|
||||||
|
|
||||||
|
final class FuelSavingsRoutingCacheTests: XCTestCase {
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 0, sourceLng: 0, entries: [:])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func station(_ id: String, _ lat: Double, _ lng: Double) -> FuelStation {
|
||||||
|
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "", lat: lat, lng: lng, prices: [.e10: 140], priceUpdated: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testJourneyDistanceUsesSeparateOutAndBackRoadRoutes() {
|
||||||
|
let s = station("a", 51.5074, -0.1278)
|
||||||
|
FuelStore.saveRoadDistances(
|
||||||
|
sourceLat: 51.6,
|
||||||
|
sourceLng: -0.1,
|
||||||
|
entries: ["a": CachedRoadDistance(meters: 3_200, lat: 51.5074, lng: -0.1278, returnMeters: 3_700)]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(FuelStore.roadJourneyMeters(for: s, userLat: 51.6, userLng: -0.1), 6_900)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLegacyOneWayCacheDoublesForJourneyDistance() {
|
||||||
|
let s = station("a", 51.5074, -0.1278)
|
||||||
|
FuelStore.saveRoadDistances(
|
||||||
|
sourceLat: 51.6,
|
||||||
|
sourceLng: -0.1,
|
||||||
|
entries: ["a": CachedRoadDistance(meters: 3_200, lat: 51.5074, lng: -0.1278)]
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(FuelStore.roadJourneyMeters(for: s, userLat: 51.6, userLng: -0.1), 6_400)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import XCTest
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
|
@testable import FuelBoardShared
|
||||||
|
#endif
|
||||||
|
|
||||||
|
final class FuelSavingsCalculatorTests: XCTestCase {
|
||||||
|
func testUKMPGConversionUsesImperialGallons() {
|
||||||
|
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 10, mpg: 45), 1.0102, accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPoundsBudgetConvertsToLitresUsingBaselinePrice() throws {
|
||||||
|
let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||||
|
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160)
|
||||||
|
XCTAssertEqual(litres, 25.0, accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLitresAmountPassesThrough() throws {
|
||||||
|
let amount = FuelSavingsFillAmount(value: 35, unit: .litres)
|
||||||
|
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160)
|
||||||
|
XCTAssertEqual(litres, 35, accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNetSavingSubtractsAdditionalDrivingFuelCost() throws {
|
||||||
|
let result = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 45,
|
||||||
|
amount: FuelSavingsFillAmount(value: 40, unit: .pounds),
|
||||||
|
baselinePricePencePerLitre: 160,
|
||||||
|
candidatePricePencePerLitre: 150,
|
||||||
|
baselineJourneyMiles: 2,
|
||||||
|
candidateJourneyMiles: 8
|
||||||
|
)
|
||||||
|
XCTAssertEqual(result.fillLitres, 25, accuracy: 0.0001)
|
||||||
|
XCTAssertEqual(result.grossSavingGBP, 2.50, accuracy: 0.0001)
|
||||||
|
XCTAssertEqual(result.extraDistanceMiles, 6, accuracy: 0.0001)
|
||||||
|
XCTAssertEqual(result.extraDrivingCostGBP, 0.9092, accuracy: 0.0001)
|
||||||
|
XCTAssertEqual(result.netSavingGBP, 1.5908, accuracy: 0.0001)
|
||||||
|
XCTAssertEqual(result.status, .worthTheDrive)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNegativeAndBreakEvenOutcomes() throws {
|
||||||
|
let negative = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 45,
|
||||||
|
amount: FuelSavingsFillAmount(value: 10, unit: .litres),
|
||||||
|
baselinePricePencePerLitre: 150,
|
||||||
|
candidatePricePencePerLitre: 149,
|
||||||
|
baselineJourneyMiles: 1,
|
||||||
|
candidateJourneyMiles: 20
|
||||||
|
)
|
||||||
|
XCTAssertEqual(negative.status, .notWorthTheDrive)
|
||||||
|
XCTAssertLessThan(negative.netSavingGBP, 0)
|
||||||
|
|
||||||
|
let even = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: 45,
|
||||||
|
amount: FuelSavingsFillAmount(value: 10, unit: .litres),
|
||||||
|
baselinePricePencePerLitre: 150,
|
||||||
|
candidatePricePencePerLitre: 150,
|
||||||
|
baselineJourneyMiles: 2,
|
||||||
|
candidateJourneyMiles: 2
|
||||||
|
)
|
||||||
|
XCTAssertEqual(even.status, .breakEven)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInvalidInputsRejected() {
|
||||||
|
XCTAssertThrowsError(try FuelSavingsCalculator.fillLitres(amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 0))
|
||||||
|
XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 0, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2))
|
||||||
|
XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 500, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSwitchingPoundsToLitresPreservesPhysicalQuantity() throws {
|
||||||
|
let pounds = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||||
|
let litres = try FuelSavingsCalculator.convert(amount: pounds, to: .litres, referencePricePencePerLitre: 160)
|
||||||
|
XCTAssertEqual(litres.unit, .litres)
|
||||||
|
XCTAssertEqual(litres.value, 25, accuracy: 0.0001)
|
||||||
|
let back = try FuelSavingsCalculator.convert(amount: litres, to: .pounds, referencePricePencePerLitre: 160)
|
||||||
|
XCTAssertEqual(back.value, 40, accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSortingPlacesUnavailableAfterSavingsAndBreaksTies() {
|
||||||
|
let a = FuelSavingsRankedStation(stationID: "a", result: .available(.init(fillLitres: 25, grossSavingGBP: 2, extraDrivingCostGBP: 1, extraDistanceMiles: 3, netSavingGBP: 1, status: .worthTheDrive)), distanceMiles: 5, pricePencePerLitre: 150)
|
||||||
|
let b = FuelSavingsRankedStation(stationID: "b", result: .available(.init(fillLitres: 25, grossSavingGBP: 3, extraDrivingCostGBP: 1, extraDistanceMiles: 2, netSavingGBP: 2, status: .worthTheDrive)), distanceMiles: 8, pricePencePerLitre: 149)
|
||||||
|
let c = FuelSavingsRankedStation(stationID: "c", result: .unavailable("No route"), distanceMiles: 1, pricePencePerLitre: 140)
|
||||||
|
XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([a, c, b]).map(\.stationID), ["b", "a", "c"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBestValueAlertCandidateRequiresConfiguredSavingsAndThreshold() {
|
||||||
|
let stations = [
|
||||||
|
station("near", lat: 0, lng: 0, price: 160),
|
||||||
|
station("cheap", lat: 0, lng: 0, price: 150),
|
||||||
|
]
|
||||||
|
let journeys = ["near": 2.0, "cheap": 4.0]
|
||||||
|
let off = FuelSavingsSettings(enabled: false, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds))
|
||||||
|
XCTAssertNil(FuelSavingsCalculator.bestValueAlertCandidate(
|
||||||
|
stations: stations,
|
||||||
|
fuel: .e10,
|
||||||
|
settings: off,
|
||||||
|
amount: off.preferredAmount,
|
||||||
|
minimumNetSavingGBP: 1.0,
|
||||||
|
journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } }
|
||||||
|
))
|
||||||
|
|
||||||
|
let on = FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds))
|
||||||
|
XCTAssertNil(FuelSavingsCalculator.bestValueAlertCandidate(
|
||||||
|
stations: stations,
|
||||||
|
fuel: .e10,
|
||||||
|
settings: on,
|
||||||
|
amount: on.preferredAmount,
|
||||||
|
minimumNetSavingGBP: 5.0,
|
||||||
|
journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBestValueAlertCandidatePicksHighestNetSavingAboveThreshold() throws {
|
||||||
|
let stations = [
|
||||||
|
station("baseline", lat: 0, lng: 0, price: 160),
|
||||||
|
station("small", lat: 0, lng: 0, price: 154),
|
||||||
|
station("best", lat: 0, lng: 0, price: 148),
|
||||||
|
]
|
||||||
|
let journeys = ["baseline": 2.0, "small": 4.0, "best": 6.0]
|
||||||
|
let settings = FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds))
|
||||||
|
let candidate = FuelSavingsCalculator.bestValueAlertCandidate(
|
||||||
|
stations: stations,
|
||||||
|
fuel: .e10,
|
||||||
|
settings: settings,
|
||||||
|
amount: settings.preferredAmount,
|
||||||
|
minimumNetSavingGBP: 1.0,
|
||||||
|
journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } }
|
||||||
|
)
|
||||||
|
XCTAssertEqual(candidate?.station.id, "best")
|
||||||
|
XCTAssertEqual(candidate?.result.status, .worthTheDrive)
|
||||||
|
XCTAssertGreaterThan(candidate?.result.netSavingGBP ?? 0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBestValueAlertCandidateBreaksTiesByDistanceThenPrice() throws {
|
||||||
|
let first = FuelSavingsResult(fillLitres: 25, grossSavingGBP: 3, extraDrivingCostGBP: 1, extraDistanceMiles: 2, netSavingGBP: 2, status: .worthTheDrive)
|
||||||
|
let nearer = FuelSavingsRankedStation(stationID: "nearer", result: .available(first), distanceMiles: 4, pricePencePerLitre: 149)
|
||||||
|
let farther = FuelSavingsRankedStation(stationID: "farther", result: .available(first), distanceMiles: 8, pricePencePerLitre: 145)
|
||||||
|
XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([farther, nearer]).first?.stationID, "nearer")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func station(_ id: String, lat: Double, lng: Double, price: Double) -> FuelStation {
|
||||||
|
FuelStation(id: id, name: id, brand: id, address: "", postcode: "", lat: lat, lng: lng, prices: [.e10: price], priceUpdated: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class FuelSavingsSettingsTests: XCTestCase {
|
||||||
|
func testSettingsValidationRequiresEnabledMPGAndFillAmount() {
|
||||||
|
XCTAssertFalse(FuelSavingsSettings(enabled: false, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
|
||||||
|
XCTAssertFalse(FuelSavingsSettings(enabled: true, mpg: 0, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
|
||||||
|
XCTAssertFalse(FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 0, unit: .pounds)).isConfigured)
|
||||||
|
XCTAssertTrue(FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import FuelBoardShared
|
||||||
|
|
||||||
|
/// Shared payload storage must survive an install whose provisioning profile
|
||||||
|
/// has NO `com.apple.security.application-groups` entitlement (free/personal
|
||||||
|
/// team and SideStore installs) — the exact situation this test runner is in.
|
||||||
|
///
|
||||||
|
/// Regression guard: `appGroupFileURL` used to return nil without a group
|
||||||
|
/// container, so every save silently failed and every load returned empty. That
|
||||||
|
/// left `loadStations()` permanently empty (letting the freshness gate skip the
|
||||||
|
/// fetch for ever, i.e. a blank station list) and re-downloaded the ~2.8 MB
|
||||||
|
/// dump on every refresh. Storage now falls back to the app's own container.
|
||||||
|
final class FuelStoreFileCacheTests: XCTestCase {
|
||||||
|
|
||||||
|
func testSharedFileURLExistsWithoutAppGroupContainer() {
|
||||||
|
XCTAssertNotNil(
|
||||||
|
FuelStore.appGroupFileURL("fuelboard.test.probe.json"),
|
||||||
|
"storage must resolve to a writable location even when the App Group container is unprovisioned"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSharedFileRoundTripsWithoutAppGroupContainer() throws {
|
||||||
|
let name = "fuelboard.test.roundtrip.json"
|
||||||
|
let payload = Data("hello-cache".utf8)
|
||||||
|
|
||||||
|
XCTAssertTrue(
|
||||||
|
FuelStore.saveAppGroupFile(payload, name: name),
|
||||||
|
"save must succeed without an App Group container"
|
||||||
|
)
|
||||||
|
XCTAssertEqual(FuelStore.loadAppGroupFile(name), payload)
|
||||||
|
|
||||||
|
if let url = FuelStore.appGroupFileURL(name) {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dump cache is what stops a ~2.8 MB re-download on every refresh, so
|
||||||
|
/// it must round-trip too — not just the station list.
|
||||||
|
func testDumpCacheRoundTripsWithoutAppGroupContainer() {
|
||||||
|
let day = "2026-01-01"
|
||||||
|
let data = Data(repeating: 7, count: 4096)
|
||||||
|
|
||||||
|
MirrorFuelProvider.saveDumpCache(day: day, data: data)
|
||||||
|
let cache = MirrorFuelProvider.loadDumpCache()
|
||||||
|
|
||||||
|
XCTAssertEqual(cache?.day, day, "cached dump day must survive a round-trip")
|
||||||
|
XCTAssertEqual(cache?.data, data, "cached dump payload must survive a round-trip")
|
||||||
|
|
||||||
|
if let url = FuelStore.appGroupFileURL(MirrorFuelProvider.liveDumpCacheFileName) {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The road-distance cache is memoized in memory because re-reading it per
|
||||||
|
/// station (it lives in the keychain) stalled the main thread past the iOS
|
||||||
|
/// launch watchdog. A save must therefore invalidate the memo — a stale
|
||||||
|
/// in-memory cache would show wrong distances after a route refresh.
|
||||||
|
func testRoadDistanceMemoRefreshesAfterSave() {
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 51.0, sourceLng: -1.0, entries: [:])
|
||||||
|
XCTAssertEqual(FuelStore.loadRoadDistances()?.sourceLat, 51.0)
|
||||||
|
|
||||||
|
FuelStore.saveRoadDistances(sourceLat: 52.0, sourceLng: -2.0, entries: [:])
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
FuelStore.loadRoadDistances()?.sourceLat, 52.0,
|
||||||
|
"load must reflect the newest save, not a stale memoized cache"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,13 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
@testable import FuelBoardShared
|
@testable import FuelBoardShared
|
||||||
|
#endif
|
||||||
|
|
||||||
/// P0 live chain: GitHub mirror FIRST everywhere → [dev-only LAN relay] →
|
/// P0 live chain: public GitHub mirror first everywhere →
|
||||||
/// (app: bundled dump in ContentView's catch, widget: cache → placeholder).
|
/// (app: bundled dump in ContentView's catch, widget: cache → placeholder).
|
||||||
/// The relay is NOT part of the consumer chain — it only joins behind the
|
/// No LAN relay fallback is compiled into the app binary.
|
||||||
/// hidden dev flag `fuelboard.relayFallback`.
|
|
||||||
final class LiveChainTests: XCTestCase {
|
final class LiveChainTests: XCTestCase {
|
||||||
|
|
||||||
override func setUp() {
|
|
||||||
super.setUp()
|
|
||||||
FuelBeacon.isEnabled = false // chain tests must not fire real pings
|
|
||||||
FuelStore.saveRelayFallbackEnabled(false) // dev flag off by default
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
FuelBeacon.isEnabled = true
|
|
||||||
super.tearDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Fixtures
|
// MARK: Fixtures
|
||||||
|
|
||||||
private final class StubProvider: FuelPriceProviding {
|
private final class StubProvider: FuelPriceProviding {
|
||||||
@@ -43,86 +33,56 @@ final class LiveChainTests: XCTestCase {
|
|||||||
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil)
|
prices: [.e10: 137.9, .e5: 144.9, .diesel: 144.9], priceUpdated: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Full path (app refresh): GitHub first
|
// MARK: Full path (app refresh): GitHub mirror only
|
||||||
|
|
||||||
func testFullPathPrefersMirror() async throws {
|
func testFullPathUsesMirror() async throws {
|
||||||
let mirror = StubProvider(result: [fixtureStation()])
|
let mirror = StubProvider(result: [fixtureStation()])
|
||||||
let relay = StubProvider(error: FuelProviderError.relayUnavailable)
|
let chain = LiveChainProvider(mirror: mirror)
|
||||||
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
|
||||||
|
|
||||||
let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
|
let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
|
||||||
|
|
||||||
XCTAssertEqual(stations.count, 1)
|
XCTAssertEqual(stations.count, 1)
|
||||||
XCTAssertEqual(mirror.callCount, 1, "full path must try the GitHub mirror first")
|
XCTAssertEqual(mirror.callCount, 1, "full path must use the public GitHub mirror")
|
||||||
XCTAssertEqual(relay.callCount, 0, "relay must not run when the mirror serves")
|
|
||||||
XCTAssertEqual(LiveChainProvider.lastSource, "github")
|
XCTAssertEqual(LiveChainProvider.lastSource, "github")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFullPathRelaySkippedWhenDisabled() async {
|
func testFullPathPropagatesMirrorFailure() async {
|
||||||
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
|
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
|
||||||
let relay = StubProvider(result: [fixtureStation("relay")])
|
let chain = LiveChainProvider(mirror: mirror)
|
||||||
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
|
_ = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
|
||||||
XCTFail("chain must throw when the mirror fails and the relay flag is off")
|
XCTFail("chain must throw when the public mirror fails; callers decide cache/bundled fallback")
|
||||||
} catch {
|
} catch {
|
||||||
XCTAssertEqual(relay.callCount, 0, "consumers must NEVER attempt the relay")
|
XCTAssertEqual(mirror.callCount, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFullPathRelayFallbackWhenDevFlagOn() async throws {
|
// MARK: Focused path (alert checks + widget): GitHub mirror only
|
||||||
FuelStore.saveRelayFallbackEnabled(true)
|
|
||||||
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
|
|
||||||
let relay = StubProvider(result: [fixtureStation("relay")])
|
|
||||||
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
|
||||||
|
|
||||||
let stations = try await chain.fetchStations(near: nil, lng: nil, fuel: .e10, radiusKM: nil)
|
func testFocusedPathUsesMirror() async throws {
|
||||||
|
|
||||||
XCTAssertEqual(stations.first?.id, "relay")
|
|
||||||
XCTAssertEqual(relay.callCount, 1, "dev flag on → relay fallback serves")
|
|
||||||
XCTAssertEqual(LiveChainProvider.lastSource, "relay")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Focused path (alert checks + widget): GitHub first too
|
|
||||||
|
|
||||||
func testFocusedPathPrefersMirror() async throws {
|
|
||||||
let mirror = StubProvider(result: [fixtureStation("mirror")])
|
let mirror = StubProvider(result: [fixtureStation("mirror")])
|
||||||
let relay = StubProvider(result: [fixtureStation("relay")])
|
let chain = LiveChainProvider(mirror: mirror)
|
||||||
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
|
||||||
|
|
||||||
let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
|
let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
|
||||||
|
|
||||||
XCTAssertEqual(stations.first?.id, "mirror", "focused path must prefer the mirror — the relay is unreachable off-LAN")
|
XCTAssertEqual(stations.first?.id, "mirror", "focused path must use the public GitHub mirror")
|
||||||
XCTAssertEqual(relay.callCount, 0)
|
XCTAssertEqual(mirror.callCount, 1)
|
||||||
XCTAssertEqual(LiveChainProvider.lastSource, "github")
|
XCTAssertEqual(LiveChainProvider.lastSource, "github")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFocusedPathRelaySkippedWhenDisabled() async {
|
func testFocusedPathPropagatesMirrorFailure() async {
|
||||||
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
|
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
|
||||||
let relay = StubProvider(result: [fixtureStation("relay")])
|
let chain = LiveChainProvider(mirror: mirror)
|
||||||
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
|
_ = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
|
||||||
XCTFail("focused path must throw when the mirror fails and the relay flag is off")
|
XCTFail("focused path must throw when the public mirror fails")
|
||||||
} catch {
|
} catch {
|
||||||
XCTAssertEqual(relay.callCount, 0, "consumers must NEVER attempt the relay")
|
XCTAssertEqual(mirror.callCount, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFocusedPathRelayFallbackWhenDevFlagOn() async throws {
|
|
||||||
FuelStore.saveRelayFallbackEnabled(true)
|
|
||||||
let mirror = StubProvider(error: FuelProviderError.mirrorUnavailable)
|
|
||||||
let relay = StubProvider(result: [fixtureStation("relay")])
|
|
||||||
let chain = LiveChainProvider(mirror: mirror, relay: relay)
|
|
||||||
|
|
||||||
let stations = try await chain.fetchStations(near: 53.7, lng: -1.8, fuel: .e10, radiusKM: 5)
|
|
||||||
|
|
||||||
XCTAssertEqual(stations.first?.id, "relay")
|
|
||||||
XCTAssertEqual(relay.callCount, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Mirror focused projection (replaces the relay's server-side radius)
|
// MARK: Mirror focused projection (replaces the relay's server-side radius)
|
||||||
|
|
||||||
func testMirrorFocusedFiltersByFuelRadiusDistanceAndLimit() {
|
func testMirrorFocusedFiltersByFuelRadiusDistanceAndLimit() {
|
||||||
@@ -163,24 +123,4 @@ final class LiveChainTests: XCTestCase {
|
|||||||
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: nil))
|
XCTAssertFalse(MirrorFuelProvider.canReuseCache(cachedDay: "2026-08-15", latestDay: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Beacon
|
|
||||||
|
|
||||||
func testBeaconURLCarriesAppAttribution() {
|
|
||||||
guard let url = FuelBeacon.beaconURL(source: "github", n: 8022) else {
|
|
||||||
return XCTFail("beacon URL must build")
|
|
||||||
}
|
|
||||||
XCTAssertTrue(url.absoluteString.contains("api/v1/widget-diag"))
|
|
||||||
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
|
|
||||||
XCTAssertTrue(items.contains(URLQueryItem(name: "intent", value: "app-live")))
|
|
||||||
XCTAssertTrue(items.contains(URLQueryItem(name: "source", value: "github")))
|
|
||||||
XCTAssertTrue(items.contains(URLQueryItem(name: "n", value: "8022")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testBeaconGatedByDevFlag() {
|
|
||||||
FuelBeacon.isEnabled = true
|
|
||||||
FuelStore.saveRelayFallbackEnabled(false)
|
|
||||||
XCTAssertFalse(FuelBeacon.shouldFire, "consumers: beacon must never fire (no local-network attempt)")
|
|
||||||
FuelStore.saveRelayFallbackEnabled(true)
|
|
||||||
XCTAssertTrue(FuelBeacon.shouldFire, "dev flag on: beacon fires for attribution")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import FuelBoardShared
|
||||||
|
|
||||||
|
/// User-facing copy: the Fuel Savings SORT is called "Best Value" (renamed on the
|
||||||
|
/// user's instruction from "Worth the Drive"), while the feature that backs it
|
||||||
|
/// keeps the name "Worth the Drive" in Settings and on the sheet. Pin both so
|
||||||
|
/// neither drifts by accident.
|
||||||
|
final class SortModeNamesTests: XCTestCase {
|
||||||
|
|
||||||
|
func testSortModeNames() {
|
||||||
|
XCTAssertEqual(SortMode.cheapest.displayName, "Cheapest")
|
||||||
|
XCTAssertEqual(SortMode.closest.displayName, "Closest")
|
||||||
|
XCTAssertEqual(SortMode.worthTheDrive.displayName, "Best Value")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRawValueIsUnchangedSoStoredPreferencesKeepDecoding() {
|
||||||
|
// The label is copy; the raw value is persisted state. Renaming the one
|
||||||
|
// must never move the other.
|
||||||
|
XCTAssertEqual(SortMode.worthTheDrive.rawValue, "worthTheDrive")
|
||||||
|
XCTAssertEqual(SortMode(rawValue: "worthTheDrive"), .worthTheDrive)
|
||||||
|
XCTAssertEqual(SortMode.allCases.map(\.rawValue), ["cheapest", "closest", "worthTheDrive"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTheVerdictWordingIsUnchanged() {
|
||||||
|
// The feature and its sort are both called "Best Value" in user-facing
|
||||||
|
// copy now (Settings toggle + narrative, the sheet's section header).
|
||||||
|
// FuelSavingsStatus's own wording describes a VERDICT rather than the
|
||||||
|
// feature, and is not shown in the UI, so it keeps the drive phrasing.
|
||||||
|
XCTAssertEqual(FuelSavingsStatus.worthTheDrive.displayName, "Worth the drive")
|
||||||
|
XCTAssertEqual(FuelSavingsStatus.notWorthTheDrive.displayName, "Not worth the drive")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import FuelBoardShared
|
||||||
|
|
||||||
|
/// The row and the detail sheet show the station name themselves, so the address
|
||||||
|
/// line must not repeat it — and must not eat a genuine street address either.
|
||||||
|
/// Every address below is a real string from the Fuel Finder dump.
|
||||||
|
final class StationAddressLineTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: - The name is dropped when the address repeats it
|
||||||
|
|
||||||
|
func testAddressStartingWithTheExactName() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationAddressLine.display(
|
||||||
|
address: "RING ROAD SERVICE STATION OSMASTON PARK ROAD, DERBY, DE23 8WL",
|
||||||
|
postcode: "DE23 8WL",
|
||||||
|
name: "Ring Road Service Station"),
|
||||||
|
"OSMASTON PARK ROAD, DERBY, DE23 8WL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNameHeldInTheFirstSegment() {
|
||||||
|
// The real "Jet Chidswell" row, which read as its own name twice.
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationAddressLine.display(
|
||||||
|
address: "CHIDSWELL SERVICE STATION, LEEDS ROAD, DEWSBURY, WF12 7QW",
|
||||||
|
postcode: "WF12 7QW",
|
||||||
|
name: "Jet Chidswell"),
|
||||||
|
"LEEDS ROAD, DEWSBURY, WF12 7QW")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNameMatchIgnoresSpacingAndPunctuation() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationAddressLine.display(
|
||||||
|
address: "TESCO EXTRA, LEEDS ROAD, LS1 1AA",
|
||||||
|
postcode: "LS1 1AA",
|
||||||
|
name: "Tesco Extra"),
|
||||||
|
"LEEDS ROAD, LS1 1AA")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - A real street address is left alone
|
||||||
|
|
||||||
|
func testStreetAddressWithoutTheNameIsUntouched() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationAddressLine.display(
|
||||||
|
address: "66B Drumconvis Rd Coagh",
|
||||||
|
postcode: "",
|
||||||
|
name: "Corner filling station"),
|
||||||
|
"66B Drumconvis Rd Coagh")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHouseNumberInTheFirstSegmentBlocksTheStrip() {
|
||||||
|
// "41 ANNACLOY RD ANNACLOY" is a street address, not a restatement of
|
||||||
|
// the name — stripping it would delete where the station actually is.
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationAddressLine.display(
|
||||||
|
address: "41 ANNACLOY RD ANNACLOY",
|
||||||
|
postcode: "",
|
||||||
|
name: "ANNACLOY FILLING STATION"),
|
||||||
|
"41 ANNACLOY RD ANNACLOY")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnrelatedNameIsKept() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationAddressLine.display(
|
||||||
|
address: "BALDWINS GATE FILLING STATION BALDWINS GATE, NEWCASTLE, ST5 5DA",
|
||||||
|
postcode: "ST5 5DA",
|
||||||
|
name: "Galaxy Overseas Limited"),
|
||||||
|
"BALDWINS GATE FILLING STATION BALDWINS GATE, NEWCASTLE, ST5 5DA")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDigitFreeSegmentThatIsNotTheNameIsKept() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationAddressLine.display(
|
||||||
|
address: "HIGH STREET, LEEDS, LS1 1AA",
|
||||||
|
postcode: "LS1 1AA",
|
||||||
|
name: "Texaco Garage"),
|
||||||
|
"HIGH STREET, LEEDS, LS1 1AA")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPartialWordPrefixNeverCutsIntoAWord() {
|
||||||
|
// "Jet" matches the first letters of "JETSON"; the address is not the name.
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationAddressLine.display(
|
||||||
|
address: "JETSON FILLING STATION, LEEDS ROAD",
|
||||||
|
postcode: "",
|
||||||
|
name: "Jet"),
|
||||||
|
"JETSON FILLING STATION, LEEDS ROAD")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The postcode is written exactly once
|
||||||
|
|
||||||
|
func testPostcodeAtTheEndOfTheAddressIsNotRepeated() {
|
||||||
|
let line = StationAddressLine.display(
|
||||||
|
address: "5-15 High Street, LEEDS, LS1 1AA",
|
||||||
|
postcode: "LS1 1AA",
|
||||||
|
name: "Boundary Service station Gulf")
|
||||||
|
XCTAssertEqual(line, "5-15 High Street, LEEDS, LS1 1AA")
|
||||||
|
XCTAssertEqual(line.components(separatedBy: "LS1 1AA").count - 1, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPostcodeIsAppendedWhenTheAddressLacksIt() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationAddressLine.display(
|
||||||
|
address: "7 STEAM MILLS, CINDERFORD",
|
||||||
|
postcode: "GL14 3JD",
|
||||||
|
name: "Steam Mills Garage"),
|
||||||
|
"7 STEAM MILLS, CINDERFORD, GL14 3JD")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEmptyAddressFallsBackToThePostcode() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationAddressLine.display(address: " ", postcode: "LS1 1AA", name: "Anything"),
|
||||||
|
"LS1 1AA")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBlankPostcodeLeavesJustTheAddress() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationAddressLine.display(address: "76-78 BURN ROAD", postcode: "", name: "SOLO 24 HR"),
|
||||||
|
"76-78 BURN ROAD")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWhitespaceIsCollapsed() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
StationAddressLine.display(
|
||||||
|
address: "41 ANNACLOY RD ANNACLOY",
|
||||||
|
postcode: "",
|
||||||
|
name: "Somewhere Else"),
|
||||||
|
"41 ANNACLOY RD ANNACLOY")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Every station in the real dump produces a sane line
|
||||||
|
|
||||||
|
func testRealDumpNeverProducesAnEmptyOrNameLedLine() throws {
|
||||||
|
// Guards the rule against the shapes in the live data rather than the
|
||||||
|
// handful of examples above.
|
||||||
|
let stations: [(name: String, address: String, postcode: String)] = [
|
||||||
|
("Ravenspoint Trading Ltd", "RAVENSPOINT CENTRE KERSHADER, ISLE OF LEWIS, HS2 9QA", "HS2 9QA"),
|
||||||
|
("Galaxy Overseas Limited", "BALDWINS GATE FILLING STATION BALDWINS GATE, NEWCASTLE, ST5 5DA", "ST5 5DA"),
|
||||||
|
("Jet Chidswell", "CHIDSWELL SERVICE STATION, LEEDS ROAD, DEWSBURY, WF12 7QW", "WF12 7QW"),
|
||||||
|
("Corner filling station", "66B Drumconvis Rd Coagh", ""),
|
||||||
|
("Belcoo Service Station", "3, LATTONE ROAD, BELCOO EAST, BELCOO, ENNISKILLEN, BT93 5AQ", "BT93 5AQ"),
|
||||||
|
("ANNACLOY FILLING STATION", "41 ANNACLOY RD ANNACLOY", ""),
|
||||||
|
("Boundary Service station Gulf", "5-15 High Street", ""),
|
||||||
|
("SOLO 24 HR", "76-78 BURN ROAD", ""),
|
||||||
|
("Steam Mills Garage", "7 STEAM MILLS, CINDERFORD, GL14 3JD", "GL14 3JD"),
|
||||||
|
("Carnagh House Valero", "191 A Castleblayney Road Keady", ""),
|
||||||
|
]
|
||||||
|
for station in stations {
|
||||||
|
let line = StationAddressLine.display(
|
||||||
|
address: station.address, postcode: station.postcode, name: station.name)
|
||||||
|
XCTAssertFalse(line.isEmpty, "empty line for \(station.name)")
|
||||||
|
XCTAssertFalse(line.hasPrefix(" "), "leading space for \(station.name)")
|
||||||
|
XCTAssertFalse(line.hasSuffix(","), "trailing comma for \(station.name)")
|
||||||
|
XCTAssertFalse(line.contains(" "), "double space for \(station.name)")
|
||||||
|
// Never the name alone as the first words — the header has it.
|
||||||
|
let key = station.name.lowercased().filter { $0.isLetter || $0.isNumber }
|
||||||
|
let lineKey = line.lowercased().filter { $0.isLetter || $0.isNumber }
|
||||||
|
XCTAssertFalse(lineKey.hasPrefix(key), "\(station.name) still leads its own address: \(line)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"identifier" : "fuelboard-local-tips",
|
||||||
|
"nonRenewingSubscriptions" : [
|
||||||
|
|
||||||
|
],
|
||||||
|
"products" : [
|
||||||
|
{
|
||||||
|
"displayPrice" : "0.99",
|
||||||
|
"familyShareable" : false,
|
||||||
|
"internalID" : "fuelboard-tip099",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"description" : "Just enough to keep things moving.",
|
||||||
|
"displayName" : "Splash & Dash",
|
||||||
|
"locale" : "en_GB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"productID" : "com.apt.fuelboard.tip099",
|
||||||
|
"referenceName" : "FuelBoard Tip 099 - Splash & Dash",
|
||||||
|
"type" : "Consumable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"displayPrice" : "2.99",
|
||||||
|
"familyShareable" : false,
|
||||||
|
"internalID" : "fuelboard-tip299",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"description" : "A generous top-up for development.",
|
||||||
|
"displayName" : "Half a Tank",
|
||||||
|
"locale" : "en_GB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"productID" : "com.apt.fuelboard.tip299",
|
||||||
|
"referenceName" : "FuelBoard Tip 299 - Half a Tank",
|
||||||
|
"type" : "Consumable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"displayPrice" : "4.99",
|
||||||
|
"familyShareable" : false,
|
||||||
|
"internalID" : "fuelboard-tip499",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"description" : "Keeping the app on the road.",
|
||||||
|
"displayName" : "Fill 'Er Up",
|
||||||
|
"locale" : "en_GB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"productID" : "com.apt.fuelboard.tip499",
|
||||||
|
"referenceName" : "FuelBoard Tip 499 - Fill 'Er Up",
|
||||||
|
"type" : "Consumable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"subscriptionGroups" : [
|
||||||
|
|
||||||
|
],
|
||||||
|
"version" : {
|
||||||
|
"major" : 2,
|
||||||
|
"minor" : 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -157,6 +157,7 @@ private enum WatchFuelCache {
|
|||||||
static let appGroupSuite = "group.com.apt.fuelboard"
|
static let appGroupSuite = "group.com.apt.fuelboard"
|
||||||
static let favouritesKey = "fuelboard.favourites"
|
static let favouritesKey = "fuelboard.favourites"
|
||||||
static let stationsKey = "fuelboard.stations"
|
static let stationsKey = "fuelboard.stations"
|
||||||
|
static let stationsFileName = "fuelboard.stations.json"
|
||||||
static let fuelKey = "fuelboard.selectedFuel"
|
static let fuelKey = "fuelboard.selectedFuel"
|
||||||
static let distanceUnitKey = "fuelboard.distanceUnit"
|
static let distanceUnitKey = "fuelboard.distanceUnit"
|
||||||
static let priceStyleKey = "fuelboard.priceDisplayStyle"
|
static let priceStyleKey = "fuelboard.priceDisplayStyle"
|
||||||
@@ -228,7 +229,17 @@ private enum WatchFuelCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func loadStations() -> [WatchStation] {
|
static func loadStations() -> [WatchStation] {
|
||||||
guard let data = defaults()?.data(forKey: stationsKey),
|
let fileData: Data?
|
||||||
|
if let container = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: appGroupSuite
|
||||||
|
) {
|
||||||
|
let url = container.appendingPathComponent(stationsFileName)
|
||||||
|
fileData = try? Data(contentsOf: url)
|
||||||
|
} else {
|
||||||
|
fileData = nil
|
||||||
|
}
|
||||||
|
let data = fileData ?? defaults()?.data(forKey: stationsKey)
|
||||||
|
guard let data,
|
||||||
let stations = try? JSONDecoder().decode([WatchStation].self, from: data) else {
|
let stations = try? JSONDecoder().decode([WatchStation].self, from: data) else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -381,7 +392,10 @@ struct ContentView: View {
|
|||||||
let cheapest = WatchFuelCache.cheapestFavourite(in: fuelFavourites, fuel: fuelCase)
|
let cheapest = WatchFuelCache.cheapestFavourite(in: fuelFavourites, fuel: fuelCase)
|
||||||
|
|
||||||
return fuelFavourites.map { favourite in
|
return fuelFavourites.map { favourite in
|
||||||
let distanceText = lastLocation.map { distanceUnit.format(km: favourite.station.distanceKM(to: $0)) }
|
// watchOS has no routed road distances (no MKDirections here and no
|
||||||
|
// shared route cache), so EVERY watch figure is the straight-line
|
||||||
|
// fallback and must say so — same rule as the app.
|
||||||
|
let distanceText = lastLocation.map { "Approx. " + distanceUnit.format(km: favourite.station.distanceKM(to: $0)) }
|
||||||
let priceText = favourite.station.prices[fuelCase].map { WatchFuelCache.priceText($0, style: priceStyle) } ?? "—"
|
let priceText = favourite.station.prices[fuelCase].map { WatchFuelCache.priceText($0, style: priceStyle) } ?? "—"
|
||||||
return WatchFavouriteRow(
|
return WatchFavouriteRow(
|
||||||
id: favourite.id,
|
id: favourite.id,
|
||||||
@@ -469,7 +483,7 @@ struct ContentView: View {
|
|||||||
.fontWeight(.bold)
|
.fontWeight(.bold)
|
||||||
Spacer()
|
Spacer()
|
||||||
if let location = lastLocation {
|
if let location = lastLocation {
|
||||||
Text(distanceUnit.format(km: favourite.station.distanceKM(to: location)))
|
Text("Approx. " + distanceUnit.format(km: favourite.station.distanceKM(to: location)))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?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>NSPrivacyTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyTrackingDomains</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>1C8F.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -10,6 +10,7 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
|
|||||||
|
|
||||||
private let suiteName = "group.com.apt.fuelboard"
|
private let suiteName = "group.com.apt.fuelboard"
|
||||||
private let snapshotRequestKey = "fuelboard.snapshotRequest"
|
private let snapshotRequestKey = "fuelboard.snapshotRequest"
|
||||||
|
private var activationRequested = false
|
||||||
|
|
||||||
private override init() {
|
private override init() {
|
||||||
super.init()
|
super.init()
|
||||||
@@ -21,14 +22,21 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
|
|||||||
if session.delegate !== self {
|
if session.delegate !== self {
|
||||||
session.delegate = self
|
session.delegate = self
|
||||||
}
|
}
|
||||||
|
guard session.activationState != .activated else { return }
|
||||||
|
guard !activationRequested else { return }
|
||||||
|
activationRequested = true
|
||||||
session.activate()
|
session.activate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestRefresh(at date: Date = Date()) {
|
func requestRefresh(at date: Date = Date()) {
|
||||||
guard WCSession.isSupported() else { return }
|
guard WCSession.isSupported() else { return }
|
||||||
|
let session = WCSession.default
|
||||||
|
guard session.activationState == .activated else {
|
||||||
|
activate()
|
||||||
|
return
|
||||||
|
}
|
||||||
let raw = String(date.timeIntervalSince1970)
|
let raw = String(date.timeIntervalSince1970)
|
||||||
let payload = ["fuelboard.watchRefreshRequest": raw]
|
let payload = ["fuelboard.watchRefreshRequest": raw]
|
||||||
let session = WCSession.default
|
|
||||||
|
|
||||||
if session.isReachable {
|
if session.isReachable {
|
||||||
session.sendMessage(payload, replyHandler: nil, errorHandler: nil)
|
session.sendMessage(payload, replyHandler: nil, errorHandler: nil)
|
||||||
@@ -39,8 +47,12 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
|
|||||||
|
|
||||||
func requestSnapshot() {
|
func requestSnapshot() {
|
||||||
guard WCSession.isSupported() else { return }
|
guard WCSession.isSupported() else { return }
|
||||||
let payload = [snapshotRequestKey: String(Date().timeIntervalSince1970)]
|
|
||||||
let session = WCSession.default
|
let session = WCSession.default
|
||||||
|
guard session.activationState == .activated else {
|
||||||
|
activate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let payload = [snapshotRequestKey: String(Date().timeIntervalSince1970)]
|
||||||
|
|
||||||
if session.isReachable {
|
if session.isReachable {
|
||||||
session.sendMessage(payload, replyHandler: nil, errorHandler: nil)
|
session.sendMessage(payload, replyHandler: nil, errorHandler: nil)
|
||||||
@@ -82,6 +94,7 @@ final class WatchSyncManager: NSObject, WCSessionDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
|
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {
|
||||||
|
activationRequested = false
|
||||||
if let error {
|
if let error {
|
||||||
print("WATCH-SYNC activation failed: \(error.localizedDescription)")
|
print("WATCH-SYNC activation failed: \(error.localizedDescription)")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?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>NSPrivacyTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyTrackingDomains</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>1C8F.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
//
|
//
|
||||||
// Lives in the widget extension (the standard host for ActivityConfiguration).
|
// Lives in the widget extension (the standard host for ActivityConfiguration).
|
||||||
// Shows the cheapest station for the pinned fuel within the app's chosen
|
// Shows the cheapest station for the pinned fuel within the app's chosen
|
||||||
// radius. Tapping anywhere opens Apple Maps directions to that station.
|
// radius. Tapping honours Settings → Tap station: Open map opens Apple Maps
|
||||||
|
// directions to that station, More info opens the detail sheet in the app.
|
||||||
//
|
//
|
||||||
// ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
|
// ADAPTIVE LAYOUT: the Lock Screen body provides two layouts and lets
|
||||||
// ViewThatFits pick by available width, but the narrow `.small` family is NOT
|
// ViewThatFits pick by available width, but the narrow `.small` family is NOT
|
||||||
@@ -31,24 +32,34 @@ struct FuelBoardLiveActivity: Widget {
|
|||||||
} dynamicIsland: { context in
|
} dynamicIsland: { context in
|
||||||
DynamicIsland {
|
DynamicIsland {
|
||||||
DynamicIslandExpandedRegion(.leading) {
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
FuelBoardLiveActivityCompactView(context: context)
|
FuelBoardLiveActivityCompactView(context: context)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
DynamicIslandExpandedRegion(.trailing) {
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
FuelBoardLiveActivityPriceView(context: context)
|
FuelBoardLiveActivityPriceView(context: context)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
DynamicIslandExpandedRegion(.bottom) {
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
FuelBoardLiveActivityStationView(context: context)
|
FuelBoardLiveActivityStationView(context: context)
|
||||||
}
|
}
|
||||||
} compactLeading: {
|
} compactLeading: {
|
||||||
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
Image(systemName: "fuelpump.fill")
|
Image(systemName: "fuelpump.fill")
|
||||||
.foregroundStyle(context.state.fuel.tintColor)
|
.foregroundStyle(context.state.fuel.tintColor)
|
||||||
|
}
|
||||||
} compactTrailing: {
|
} compactTrailing: {
|
||||||
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
FuelBoardLiveActivityPriceView(context: context)
|
FuelBoardLiveActivityPriceView(context: context)
|
||||||
|
}
|
||||||
} minimal: {
|
} minimal: {
|
||||||
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
Text(context.state.priceText)
|
Text(context.state.priceText)
|
||||||
.font(.caption2.bold().monospacedDigit())
|
.font(.caption2.bold().monospacedDigit())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.supplementalActivityFamilies([.small])
|
.supplementalActivityFamilies([.small])
|
||||||
// Why `.small` is kept: it lets the SHARED body render a compact form
|
// Why `.small` is kept: it lets the SHARED body render a compact form
|
||||||
// in genuinely small slots (CarPlay small / Apple Watch smart stack)
|
// in genuinely small slots (CarPlay small / Apple Watch smart stack)
|
||||||
@@ -84,7 +95,7 @@ private struct FuelBoardLiveActivityView: View {
|
|||||||
@State private var slotWidth: CGFloat = 400
|
@State private var slotWidth: CGFloat = 400
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Link(destination: context.state.mapsURL) {
|
Link(destination: tapDestination) {
|
||||||
Group {
|
Group {
|
||||||
// Branch on the ACTUAL proposed width. iPhone/iPad offer the
|
// Branch on the ACTUAL proposed width. iPhone/iPad offer the
|
||||||
// full Lock Screen width (>= threshold) → rich card, no matter
|
// full Lock Screen width (>= threshold) → rich card, no matter
|
||||||
@@ -124,12 +135,21 @@ private struct FuelBoardLiveActivityView: View {
|
|||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Cheapest \(context.state.fuel.displayName)")
|
Text("Cheapest \(context.state.fuel.displayName)")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
// Name and distance are SEPARATE texts: the "Approx." marker
|
||||||
|
// lengthened this line, and as one tail-truncated string the
|
||||||
|
// FIGURE was what got cut ("… · Approx…"). The distance keeps its
|
||||||
|
// intrinsic width and the NAME truncates instead — the number is
|
||||||
|
// the informative part.
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(context.state.stationName)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
Text("· \(context.state.distanceText)")
|
||||||
|
.lineLimit(1)
|
||||||
|
.layoutPriority(1)
|
||||||
|
}
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.75)
|
|
||||||
.truncationMode(.tail)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
@@ -138,7 +158,8 @@ private struct FuelBoardLiveActivityView: View {
|
|||||||
FuelStore.priceTextAttributed(context.state.pricePence,
|
FuelStore.priceTextAttributed(context.state.pricePence,
|
||||||
style: context.state.priceDisplayStyle,
|
style: context.state.priceDisplayStyle,
|
||||||
size: 22, weight: .bold)
|
size: 22, weight: .bold)
|
||||||
Text("Tap for directions")
|
Text(FuelStore.loadStationTapAction() == .showDetails
|
||||||
|
? "Tap for details" : "Tap for directions")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -210,18 +231,47 @@ private struct FuelBoardLiveActivityStationView: View {
|
|||||||
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
let context: ActivityViewContext<FuelBoardLiveActivityAttributes>
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text("\(context.state.stationName) · \(context.state.distanceText)")
|
Link(destination: FuelBoardLiveActivityTap.tapDestination(for: context.state)) {
|
||||||
.font(.caption)
|
// Same split as the Lock Screen card: keep the distance and its
|
||||||
|
// "Approx." marker whole, truncate the name.
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(context.state.stationName)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
Text("· \(context.state.distanceText)")
|
||||||
|
.lineLimit(1)
|
||||||
|
.layoutPriority(1)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tap routing shared by the Lock Screen card and the island: honours
|
||||||
|
/// Settings → Tap station (Open map → Maps, More info → in-app detail).
|
||||||
|
enum FuelBoardLiveActivityTap {
|
||||||
|
static func tapDestination(for state: FuelBoardLiveActivityAttributes.ContentState) -> URL {
|
||||||
|
FuelStore.loadStationTapAction() == .showDetails ? state.detailURL : state.mapsURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension FuelBoardLiveActivityView {
|
||||||
|
/// Lock Screen tap honours Settings → Tap station.
|
||||||
|
var tapDestination: URL {
|
||||||
|
FuelBoardLiveActivityTap.tapDestination(for: context.state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ContentState display helpers
|
// MARK: - ContentState display helpers
|
||||||
|
|
||||||
extension FuelBoardLiveActivityAttributes.ContentState {
|
extension FuelBoardLiveActivityAttributes.ContentState {
|
||||||
/// Display distance using the user's chosen unit (miles default).
|
/// Display distance using the user's chosen unit (miles default), marked
|
||||||
|
/// "Approx." when the figure is the straight-line fallback rather than a
|
||||||
|
/// routed road distance — the same rule and formatter as the app's station
|
||||||
|
/// rows, so no surface can claim more confidence than it has.
|
||||||
var distanceText: String {
|
var distanceText: String {
|
||||||
FuelStore.loadDistanceUnit().format(distanceKM)
|
StationDistanceDisplay(km: distanceKM, isApproximate: distanceIsApproximate ?? false)
|
||||||
|
.label(unit: FuelStore.loadDistanceUnit())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Price string from pence, in the user's chosen display style
|
/// Price string from pence, in the user's chosen display style
|
||||||
@@ -235,4 +285,21 @@ extension FuelBoardLiveActivityAttributes.ContentState {
|
|||||||
var mapsURL: URL {
|
var mapsURL: URL {
|
||||||
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")!
|
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// In-app deep link to the station's detail sheet, used when
|
||||||
|
/// Settings → Tap station = More info. Carries coordinate/name fallback
|
||||||
|
/// so the app can still show the map sheet when the station has left
|
||||||
|
/// the cache.
|
||||||
|
var detailURL: URL {
|
||||||
|
var components = URLComponents()
|
||||||
|
components.scheme = "fuelboard"
|
||||||
|
components.host = "station"
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "stationID", value: stationID),
|
||||||
|
URLQueryItem(name: "lat", value: String(lat)),
|
||||||
|
URLQueryItem(name: "lng", value: String(lng)),
|
||||||
|
URLQueryItem(name: "name", value: stationName),
|
||||||
|
]
|
||||||
|
return components.url!
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -82,8 +82,8 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
|
|
||||||
/// Diagnostics beacon tag: empty → derive from the intent type name. A
|
/// Diagnostics beacon tag: empty → derive from the intent type name. A
|
||||||
/// widget kind that shares an intent (the A/B test small widget reuses the
|
/// widget kind that shares an intent (the A/B test small widget reuses the
|
||||||
/// medium intent) passes an explicit tag so its beacons stay
|
/// medium intent) passes an explicit tag so its keychain diagnostic slots
|
||||||
/// distinguishable in the relay log and keychain slots.
|
/// stay distinguishable.
|
||||||
var beaconTag: String = ""
|
var beaconTag: String = ""
|
||||||
|
|
||||||
func placeholder(in context: Context) -> FuelPriceEntry {
|
func placeholder(in context: Context) -> FuelPriceEntry {
|
||||||
@@ -116,77 +116,84 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func makeEntry(configuration: Configuration) async -> FuelPriceEntry {
|
private func makeEntry(configuration: Configuration) async -> FuelPriceEntry {
|
||||||
let entry = await makeEntryCore(configuration: configuration)
|
let (entry, originSource) = await makeEntryCore(configuration: configuration)
|
||||||
writeDiagBeacon(entry: entry)
|
writeDiagBeacon(entry: entry, originSource: originSource)
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fire-and-forget diagnostics beacon: writes the entry state to keychain
|
/// Fire-and-forget diagnostics beacon: writes the entry state to keychain
|
||||||
/// (app-readable, one slot per intent type) and GETs the relay so its
|
/// (app-readable, one slot per intent type) and records what a widget
|
||||||
/// access log records that a widget timeline actually ran in the
|
/// timeline produced for Settings → Widget Diagnostics. Deliberately
|
||||||
/// extension and what it produced. Deliberately outside the timeline
|
/// outside the timeline result — can never affect rendering. No user
|
||||||
/// result — can never affect rendering.
|
/// coordinates are written, only which fix decided the distances.
|
||||||
private func writeDiagBeacon(entry: FuelPriceEntry) {
|
private func writeDiagBeacon(entry: FuelPriceEntry, originSource: String) {
|
||||||
let d = entry.data
|
let d = entry.data
|
||||||
let intentType = beaconTag.isEmpty ? String(describing: Configuration.self) : beaconTag
|
let intentType = beaconTag.isEmpty ? String(describing: Configuration.self) : beaconTag
|
||||||
let first = d.stations.first
|
let first = d.stations.first
|
||||||
let json = """
|
let json = """
|
||||||
{"intent":"\(intentType)","source":"\(d.locationSource)",\
|
{"intent":"\(intentType)","source":"\(d.locationSource)",\
|
||||||
|
"origin":"\(originSource)",\
|
||||||
"n":\(d.stations.count),"fuel":"\(d.fuel.rawValue)",\
|
"n":\(d.stations.count),"fuel":"\(d.fuel.rawValue)",\
|
||||||
"sort":"\(d.sort.rawValue)","fav":\(d.isFavourites),\
|
"sort":"\(d.sort.rawValue)","fav":\(d.isFavourites),\
|
||||||
"station":"\(first?.name ?? "")","price":\(first?.prices[d.fuel] ?? -1)}
|
"station":"\(first?.name ?? "")","price":\(first?.prices[d.fuel] ?? -1)}
|
||||||
"""
|
"""
|
||||||
FuelStore.saveWidgetDiag(json, intentType: intentType)
|
FuelStore.saveWidgetDiag(json, intentType: intentType)
|
||||||
// The relay GET is dev-only (same flag as the app beacon): consumers
|
// Local app-group diagnostics only. No network beacon is sent: the
|
||||||
// must never make a local-network attempt. The local app-group diag
|
// App Store build must not contain a private LAN relay fallback.
|
||||||
// write above stays — Settings → Widget Diagnostics reads that.
|
|
||||||
guard FuelStore.loadRelayFallbackEnabled() else { return }
|
|
||||||
guard var components = URLComponents(
|
|
||||||
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/widget-diag"),
|
|
||||||
resolvingAgainstBaseURL: false
|
|
||||||
) else { return }
|
|
||||||
components.queryItems = [
|
|
||||||
URLQueryItem(name: "intent", value: intentType),
|
|
||||||
URLQueryItem(name: "source", value: d.locationSource),
|
|
||||||
URLQueryItem(name: "n", value: String(d.stations.count)),
|
|
||||||
URLQueryItem(name: "fuel", value: d.fuel.rawValue),
|
|
||||||
URLQueryItem(name: "sort", value: d.sort.rawValue),
|
|
||||||
URLQueryItem(name: "fav", value: d.isFavourites ? "1" : "0"),
|
|
||||||
URLQueryItem(name: "station", value: first?.name ?? ""),
|
|
||||||
URLQueryItem(name: "price", value: String(first?.prices[d.fuel] ?? -1)),
|
|
||||||
]
|
|
||||||
guard let url = components.url else { return }
|
|
||||||
let request = RelayFuelProvider.relayRequest(url, client: "widget", timeout: 2)
|
|
||||||
Task {
|
|
||||||
_ = try? await URLSession.shared.data(for: request)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeEntryCore(configuration: Configuration) async -> FuelPriceEntry {
|
private func makeEntryCore(configuration: Configuration) async -> (FuelPriceEntry, String) {
|
||||||
// Per-widget config: fuel + sort + distance come from THIS widget instance.
|
// Per-widget config: fuel + sort + distance come from THIS widget instance.
|
||||||
let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10
|
let fuel = FuelType(rawValue: configuration.fuel.rawValue) ?? .e10
|
||||||
let isFavourites = configuration.sort == .favourites
|
let isFavourites = configuration.sort == .favourites
|
||||||
let sort: SortMode = configuration.sort == .closest ? .closest : .cheapest
|
let sort: SortMode = configuration.sort == .closest ? .closest : .cheapest
|
||||||
let radiusKM = Double(configuration.distance.miles) * 1.60934
|
let radiusKM = Double(configuration.distance.miles) * 1.60934
|
||||||
|
|
||||||
// 1) Try a fresh location fix (bounded to a few seconds).
|
// 1) Try a fresh location fix (bounded to a few seconds). The fix's own
|
||||||
var location = await WidgetLocationFetcher.shared.currentLocation().map {
|
// accuracy is kept: the extension runs its own CLLocationManager and is
|
||||||
|
// routinely handed a coarse Wi-Fi/cell fix (or a cached one from earlier
|
||||||
|
// in the day), which must not be read as "the user moved away from the
|
||||||
|
// route cache".
|
||||||
|
let fix = await WidgetLocationFetcher.shared.currentLocation()
|
||||||
|
var location = fix.map {
|
||||||
Coordinate(lat: $0.coordinate.latitude, lng: $0.coordinate.longitude)
|
Coordinate(lat: $0.coordinate.latitude, lng: $0.coordinate.longitude)
|
||||||
}
|
}
|
||||||
|
let fixAccuracy = fix?.horizontalAccuracy
|
||||||
var source = "live"
|
var source = "live"
|
||||||
|
|
||||||
// 2) Fall back to the app's cached fix.
|
// 2) Fall back to the app's cached fix, then to our own last fix.
|
||||||
if location == nil, let cached = FuelStore.loadLocation() {
|
if location == nil, let cached = FuelStore.loadLocation() {
|
||||||
location = cached
|
location = cached
|
||||||
source = "cached"
|
source = "cached"
|
||||||
}
|
}
|
||||||
|
if location == nil, let own = FuelStore.loadWidgetLocation() {
|
||||||
|
location = own.coordinate
|
||||||
|
source = "widgetCached"
|
||||||
|
}
|
||||||
if location == nil {
|
if location == nil {
|
||||||
source = "none"
|
source = "none"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist a live fix so the app shows fresh coords on next launch.
|
// Persist OUR fix under its own key. It must never be written over the
|
||||||
|
// app's GPS fix: that stored coordinate is what the app seeds itself
|
||||||
|
// with, and it is the fix the route cache is anchored to.
|
||||||
if let location {
|
if let location {
|
||||||
FuelStore.saveLocation(lat: location.lat, lng: location.lng)
|
FuelStore.saveWidgetLocation(lat: location.lat, lng: location.lng)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The origin every distance on this face is measured from. Routed values
|
||||||
|
// are anchored to the app's fix, so when our own fix is coarse and lands
|
||||||
|
// away from that anchor this labels from the app's fix instead of
|
||||||
|
// collapsing every row to straight-line + "Approx." — see
|
||||||
|
// FuelStore.distanceDisplayOrigin.
|
||||||
|
var origin: Coordinate?
|
||||||
|
var originSource = "none"
|
||||||
|
if let location {
|
||||||
|
let resolved = FuelStore.distanceDisplayOrigin(candidateLat: location.lat,
|
||||||
|
candidateLng: location.lng,
|
||||||
|
candidateAccuracyMeters: fixAccuracy)
|
||||||
|
origin = Coordinate(lat: resolved.lat, lng: resolved.lng)
|
||||||
|
originSource = resolved.source
|
||||||
}
|
}
|
||||||
|
|
||||||
let unit = FuelStore.loadDistanceUnit()
|
let unit = FuelStore.loadDistanceUnit()
|
||||||
@@ -201,7 +208,7 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
// Favourites tab drag-to-reorder). Not radius-bound — a favourite
|
// Favourites tab drag-to-reorder). Not radius-bound — a favourite
|
||||||
// in Edinburgh shows on a widget in London. Prices come from the
|
// in Edinburgh shows on a widget in London. Prices come from the
|
||||||
// keychain snapshot (the app refreshes favourite prices into
|
// keychain snapshot (the app refreshes favourite prices into
|
||||||
// keychain after every fetch), or fresher from a focused relay
|
// keychain after every fetch), or fresher from a focused mirror
|
||||||
// fetch when a favourite happens to be in range. The small face
|
// fetch when a favourite happens to be in range. The small face
|
||||||
// renders the FIRST of this list — the user's top favourite for
|
// renders the FIRST of this list — the user's top favourite for
|
||||||
// the chosen fuel. (The old per-widget pinned-favourite picker
|
// the chosen fuel. (The old per-widget pinned-favourite picker
|
||||||
@@ -212,8 +219,8 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
.filter { $0.fuel == fuel && $0.station.prices[fuel] != nil }
|
.filter { $0.fuel == fuel && $0.station.prices[fuel] != nil }
|
||||||
.map(\.station)
|
.map(\.station)
|
||||||
var refreshed: [FuelStation] = fuelFavourites
|
var refreshed: [FuelStation] = fuelFavourites
|
||||||
if let location,
|
if let origin,
|
||||||
let fetched = await Self.fetchFocused(near: location, fuel: fuel, radiusKM: radiusKM) {
|
let fetched = await Self.fetchFocused(near: origin, fuel: fuel, radiusKM: radiusKM) {
|
||||||
let freshByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
|
let freshByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
|
||||||
refreshed = fuelFavourites.map { freshByID[$0.id] ?? $0 }
|
refreshed = fuelFavourites.map { freshByID[$0.id] ?? $0 }
|
||||||
}
|
}
|
||||||
@@ -223,13 +230,13 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
ordered = refreshed
|
ordered = refreshed
|
||||||
} else {
|
} else {
|
||||||
// 3a) Load stations. The widget prefers its OWN focused fetch from the
|
// 3a) Load stations. The widget prefers its OWN focused fetch from the
|
||||||
// relay so it shows real prices even when the app-group cache isn't
|
// public mirror so it shows real prices even when the app-group cache isn't
|
||||||
// shared (SideStore free accounts don't provision the group, which
|
// shared (SideStore free accounts don't provision the group, which
|
||||||
// previously left the widget stuck on sample data). Falls back to the
|
// previously left the widget stuck on sample data). Falls back to the
|
||||||
// shared cache, then to samples.
|
// shared cache, then to samples.
|
||||||
var stations: [FuelStation] = []
|
var stations: [FuelStation] = []
|
||||||
if let location {
|
if let origin {
|
||||||
if let fetched = await Self.fetchFocused(near: location, fuel: fuel, radiusKM: radiusKM) {
|
if let fetched = await Self.fetchFocused(near: origin, fuel: fuel, radiusKM: radiusKM) {
|
||||||
stations = fetched
|
stations = fetched
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -245,11 +252,14 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
}
|
}
|
||||||
if stations.isEmpty { stations = FuelStore.loadStations() }
|
if stations.isEmpty { stations = FuelStore.loadStations() }
|
||||||
if stations.isEmpty { stations = SampleFuelProvider.sampleStations }
|
if stations.isEmpty { stations = SampleFuelProvider.sampleStations }
|
||||||
if let location {
|
if let origin {
|
||||||
// STRICT: cached data fetched around another location must never
|
// STRICT: cached data fetched around another location must never
|
||||||
// leak out-of-radius stations into the widget.
|
// leak out-of-radius stations into the widget. Measured from the
|
||||||
|
// SAME origin the rows are labelled with, so the radius and the
|
||||||
|
// displayed distances can never disagree.
|
||||||
stations = stations.filter {
|
stations = stations.filter {
|
||||||
$0.distanceKM(to: location.lat, lng2: location.lng) <= radiusKM
|
FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
|
||||||
|
userLat: origin.lat, userLng: origin.lng)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let filtered = stations.filter { $0.prices[fuel] != nil }
|
let filtered = stations.filter { $0.prices[fuel] != nil }
|
||||||
@@ -257,98 +267,55 @@ struct FuelPriceTimelineProvider<Configuration: WidgetConfigurationIntent & Widg
|
|||||||
case .closest:
|
case .closest:
|
||||||
// Nearest first; price only breaks ties.
|
// Nearest first; price only breaks ties.
|
||||||
ordered = filtered.sorted { lhs, rhs in
|
ordered = filtered.sorted { lhs, rhs in
|
||||||
if let location {
|
if let origin {
|
||||||
let lDist = lhs.distanceKM(to: location.lat, lng2: location.lng)
|
let lDist = FuelStore.displayDistanceKM(station: lhs, userLat: origin.lat, userLng: origin.lng)
|
||||||
let rDist = rhs.distanceKM(to: location.lat, lng2: location.lng)
|
let rDist = FuelStore.displayDistanceKM(station: rhs, userLat: origin.lat, userLng: origin.lng)
|
||||||
if lDist != rDist { return lDist < rDist }
|
if lDist != rDist { return lDist < rDist }
|
||||||
}
|
}
|
||||||
return lhs.prices[fuel]! < rhs.prices[fuel]!
|
return lhs.prices[fuel]! < rhs.prices[fuel]!
|
||||||
}
|
}
|
||||||
case .cheapest:
|
case .cheapest, .worthTheDrive:
|
||||||
// Cheapest first; distance only breaks ties.
|
// Cheapest first; distance only breaks ties. Worth-the-Drive is app-only;
|
||||||
|
// widgets fall back to cheapest ordering if a stored config ever carries it.
|
||||||
ordered = filtered.sorted { lhs, rhs in
|
ordered = filtered.sorted { lhs, rhs in
|
||||||
let lPrice = lhs.prices[fuel]!
|
let lPrice = lhs.prices[fuel]!
|
||||||
let rPrice = rhs.prices[fuel]!
|
let rPrice = rhs.prices[fuel]!
|
||||||
if lPrice != rPrice { return lPrice < rPrice }
|
if lPrice != rPrice { return lPrice < rPrice }
|
||||||
if let location {
|
if let origin {
|
||||||
return lhs.distanceKM(to: location.lat, lng2: location.lng) <
|
return FuelStore.displayDistanceKM(station: lhs, userLat: origin.lat, userLng: origin.lng) <
|
||||||
rhs.distanceKM(to: location.lat, lng2: location.lng)
|
FuelStore.displayDistanceKM(station: rhs, userLat: origin.lat, userLng: origin.lng)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return FuelPriceEntry(
|
return (FuelPriceEntry(
|
||||||
data: FuelPriceWidgetEntryData(
|
data: FuelPriceWidgetEntryData(
|
||||||
date: Date(), stations: Array(ordered.prefix(8)),
|
date: Date(), stations: Array(ordered.prefix(8)),
|
||||||
fuel: fuel, sort: sort, isFavourites: isFavourites,
|
fuel: fuel, sort: sort, isFavourites: isFavourites,
|
||||||
location: location, locationSource: source,
|
location: origin, locationSource: source,
|
||||||
unit: unit
|
unit: unit
|
||||||
)
|
)
|
||||||
)
|
), originSource)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One focused fetch around the widget's location. GitHub mirror FIRST —
|
/// One focused fetch around the widget's location. Public HTTPS mirror only;
|
||||||
/// HTTPS, works anywhere, and the app-group day-cache usually makes it a
|
/// no private LAN fallback is attempted. Returns nil on any failure so
|
||||||
/// local decode rather than a 2.8 MB download. The LAN relay only joins
|
/// callers fall back to cache/placeholder.
|
||||||
/// behind the dev flag (fuelboard.relayFallback); consumers must never
|
|
||||||
/// attempt local-network access. Returns nil on any failure so callers
|
|
||||||
/// fall back to cache/placeholder.
|
|
||||||
private static func fetchFocused(near location: Coordinate, fuel: FuelType, radiusKM: Double) async -> [FuelStation]? {
|
private static func fetchFocused(near location: Coordinate, fuel: FuelType, radiusKM: Double) async -> [FuelStation]? {
|
||||||
if let stations = try? await MirrorFuelProvider().fetchStations(
|
try? await MirrorFuelProvider().fetchStations(
|
||||||
near: location.lat, lng: location.lng, fuel: fuel, radiusKM: radiusKM) {
|
near: location.lat, lng: location.lng, fuel: fuel, radiusKM: radiusKM)
|
||||||
return stations
|
|
||||||
}
|
|
||||||
guard FuelStore.loadRelayFallbackEnabled() else { return nil }
|
|
||||||
var components = URLComponents(
|
|
||||||
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"),
|
|
||||||
resolvingAgainstBaseURL: false
|
|
||||||
)!
|
|
||||||
components.queryItems = [
|
|
||||||
URLQueryItem(name: "fuel", value: fuel.rawValue),
|
|
||||||
URLQueryItem(name: "lat", value: String(location.lat)),
|
|
||||||
URLQueryItem(name: "lng", value: String(location.lng)),
|
|
||||||
URLQueryItem(name: "radius", value: String(radiusKM)),
|
|
||||||
URLQueryItem(name: "limit", value: "500"),
|
|
||||||
]
|
|
||||||
let request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5)
|
|
||||||
do {
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
|
||||||
return try? FuelPriceProvider.decodeStations(from: data)
|
|
||||||
} catch {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fuel-only UK-wide fetch (no lat/lng/radius) — used when the widget has
|
/// Fuel-only UK-wide fetch (no lat/lng/radius) — used when the widget has
|
||||||
/// no location fix so the face shows real stations instead of sample
|
/// no location fix so the face shows real stations instead of sample data.
|
||||||
/// data. GitHub mirror first (full dump + local fuel filter), relay only
|
/// Public HTTPS mirror only; same nil-on-failure semantics as fetchFocused.
|
||||||
/// behind the dev flag. Same nil-on-failure semantics as fetchFocused.
|
|
||||||
private static func fetchFuelOnly(fuel: FuelType, limit: Int) async -> [FuelStation]? {
|
private static func fetchFuelOnly(fuel: FuelType, limit: Int) async -> [FuelStation]? {
|
||||||
if let stations = try? await MirrorFuelProvider().fetchStations(
|
guard let stations = try? await MirrorFuelProvider().fetchStations(
|
||||||
near: nil, lng: nil, fuel: fuel, radiusKM: nil) {
|
near: nil, lng: nil, fuel: fuel, radiusKM: nil) else { return nil }
|
||||||
return Array(stations.filter { $0.prices[fuel] != nil }.prefix(limit))
|
return Array(stations.filter { $0.prices[fuel] != nil }.prefix(limit))
|
||||||
}
|
}
|
||||||
guard FuelStore.loadRelayFallbackEnabled() else { return nil }
|
|
||||||
var components = URLComponents(
|
|
||||||
url: RelayFuelProvider().baseURL.appendingPathComponent("api/v1/stations"),
|
|
||||||
resolvingAgainstBaseURL: false
|
|
||||||
)!
|
|
||||||
components.queryItems = [
|
|
||||||
URLQueryItem(name: "fuel", value: fuel.rawValue),
|
|
||||||
URLQueryItem(name: "limit", value: String(limit)),
|
|
||||||
]
|
|
||||||
let request = RelayFuelProvider.relayRequest(components.url!, client: "widget", timeout: 5)
|
|
||||||
do {
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
|
||||||
return try? FuelPriceProvider.decodeStations(from: data)
|
|
||||||
} catch {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FuelPriceWidgetView: View {
|
struct FuelPriceWidgetView: View {
|
||||||
|
|||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
# FuelBoard Privacy Policy
|
||||||
|
|
||||||
|
Effective date: 2026-09-19
|
||||||
|
|
||||||
|
FuelBoard helps users in the UK find nearby petrol, diesel, and premium fuel prices, save favourite stations, view widgets and Live Activities, and optionally receive location-based alerts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- FuelBoard does not track you across apps or websites.
|
||||||
|
- FuelBoard does not sell personal data.
|
||||||
|
- FuelBoard does not use advertising SDKs or analytics SDKs.
|
||||||
|
- FuelBoard uses location only to show nearby stations, calculate distances, power widgets, and provide optional fuel-price alerts.
|
||||||
|
- Most app preferences and cached data stay on your device.
|
||||||
|
- Fuel price data comes from public UK fuel-price datasets mirrored through GitHub raw content and a bundled offline snapshot.
|
||||||
|
|
||||||
|
## Data FuelBoard uses
|
||||||
|
|
||||||
|
### Location
|
||||||
|
|
||||||
|
FuelBoard may use your device location to:
|
||||||
|
|
||||||
|
- sort stations by distance;
|
||||||
|
- show stations within your chosen search radius;
|
||||||
|
- provide directions to a selected station;
|
||||||
|
- update home-screen widgets with nearby fuel prices;
|
||||||
|
- run optional geofenced alerts when you enable the Alerts feature.
|
||||||
|
|
||||||
|
On first launch, FuelBoard asks for When In Use location access. If you enable optional Alerts, iOS may ask you to allow Always location access so alerts can work while the app is not open.
|
||||||
|
|
||||||
|
FuelBoard stores your last known location on your device so it can refresh nearby results and widgets. FuelBoard does not sell or share your location for advertising.
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
|
||||||
|
FuelBoard may request notification permission so it can send optional fuel-price alerts. Notifications are used only for FuelBoard features such as station alerts and test alerts.
|
||||||
|
|
||||||
|
### App preferences and favourites
|
||||||
|
|
||||||
|
FuelBoard stores settings such as favourite stations, fuel type, distance unit, price display style, alert settings, widget configuration, and cached public fuel-price data on your device using Apple storage APIs such as UserDefaults and Keychain.
|
||||||
|
|
||||||
|
### Diagnostics
|
||||||
|
|
||||||
|
FuelBoard may send limited diagnostic beacons when developer relay fallback is enabled from the hidden Debug section. These requests are used to diagnose data-source and widget-refresh behaviour during testing. They are not used for advertising or tracking.
|
||||||
|
|
||||||
|
The consumer data path uses public HTTPS GitHub raw content and a bundled offline snapshot. Normal consumer use does not require a private local relay.
|
||||||
|
|
||||||
|
### Purchases
|
||||||
|
|
||||||
|
FuelBoard may offer optional consumable tip purchases. Payments are handled by Apple through StoreKit. FuelBoard does not receive or store your payment card details.
|
||||||
|
|
||||||
|
## Data FuelBoard does not collect
|
||||||
|
|
||||||
|
FuelBoard does not collect or access:
|
||||||
|
|
||||||
|
- contacts;
|
||||||
|
- photos or videos;
|
||||||
|
- camera or microphone content;
|
||||||
|
- browsing history;
|
||||||
|
- advertising identifiers for tracking;
|
||||||
|
- payment card details.
|
||||||
|
|
||||||
|
FuelBoard does not use App Tracking Transparency because it does not track users across apps or websites.
|
||||||
|
|
||||||
|
## Third-party services
|
||||||
|
|
||||||
|
FuelBoard may contact these services as part of normal app functionality:
|
||||||
|
|
||||||
|
- GitHub raw content, to download the public FuelBoard fuel-price mirror;
|
||||||
|
- Apple Maps, when you choose to open directions;
|
||||||
|
- Apple StoreKit, when optional tip purchases are available;
|
||||||
|
- Apple notification, widget, Live Activity, location, and keychain services provided by iOS.
|
||||||
|
|
||||||
|
Fuel price data is based on the UK Fuel Finder dataset made available under the Open Government Licence.
|
||||||
|
|
||||||
|
## Data retention
|
||||||
|
|
||||||
|
FuelBoard keeps local settings, favourites, cached station data, and cached price-history data on your device until you delete them, change the relevant settings, or delete the app. iOS may also remove cached app data according to system behaviour.
|
||||||
|
|
||||||
|
## Your choices
|
||||||
|
|
||||||
|
You can control FuelBoard's access at any time in iOS Settings:
|
||||||
|
|
||||||
|
- Location: Settings > Privacy & Security > Location Services > FuelBoard
|
||||||
|
- Notifications: Settings > Notifications > FuelBoard
|
||||||
|
- Widgets and Live Activities: managed through iOS widget and Live Activity controls
|
||||||
|
|
||||||
|
You can also delete FuelBoard to remove app data stored on the device, subject to normal iOS and iCloud/device-backup behaviour.
|
||||||
|
|
||||||
|
## Children's privacy
|
||||||
|
|
||||||
|
FuelBoard is not directed at children and does not knowingly collect personal information from children.
|
||||||
|
|
||||||
|
## Changes to this policy
|
||||||
|
|
||||||
|
This policy may be updated as FuelBoard changes. The effective date above will be updated when the policy changes.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
For privacy questions, use FuelBoard's listed App Store support contact or the public project issue tracker associated with the app.
|
||||||
+21
-21
@@ -4,24 +4,21 @@ Audit date: 2026-08-11 · Scope: app, widget, relay, build pipeline, git history
|
|||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
No critical issues. No secrets in source, git history, or binaries. One
|
No critical issues. No secrets in source, git history, or binaries. The prior
|
||||||
architecture note (relay IP baked into the app) is scheduled for removal by
|
LAN relay/private-IP app path has been removed from the app binary; production
|
||||||
the govuk-API switch (BACKLOG P0). Findings below are ordered by severity.
|
data access is public HTTPS GitHub mirror plus bundled fallback. Findings below
|
||||||
|
are ordered by severity.
|
||||||
|
|
||||||
## Findings
|
## Findings
|
||||||
|
|
||||||
### 1. LAN IP baked into app binary — MEDIUM (planned fix)
|
### 1. LAN IP baked into app binary — RESOLVED
|
||||||
|
|
||||||
- `Shared/FuelPriceProvider.swift:52` — `baseURL = URL(string: "http://192.168.1.131:8788")!`
|
- The app no longer contains `RelayFuelProvider`, `fuelboard.relayFallback`,
|
||||||
- Confirmed present in the built app binary (`strings`).
|
`NSLocalNetworkUsageDescription`, or a private `192.168.*` live-data endpoint.
|
||||||
- **Why it works**: ATS exempts raw numeric IP addresses (documented Apple
|
- Live data uses the public HTTPS GitHub mirror; the app falls back to bundled
|
||||||
behaviour), so no `NSAllowsArbitraryLoads` exception exists in the plist —
|
data/cache when the mirror is unavailable.
|
||||||
the plist is clean.
|
- A built-binary `strings` scan should remain part of every release gate to
|
||||||
- **Risk**: the relay is a private LAN host; the app is unusable off the
|
ensure no private LAN endpoint is reintroduced.
|
||||||
user's network, and the URL is effectively a hardcoded endpoint.
|
|
||||||
- **Fix**: the govuk API switch (BACKLOG P0) replaces this with the HTTPS
|
|
||||||
gov.uk endpoint via the relay. For any future public build, the app should
|
|
||||||
talk to an HTTPS domain, never a raw IP.
|
|
||||||
|
|
||||||
### 2. No secrets in source / git / binary — PASS
|
### 2. No secrets in source / git / binary — PASS
|
||||||
|
|
||||||
@@ -31,8 +28,9 @@ the govuk-API switch (BACKLOG P0). Findings below are ordered by severity.
|
|||||||
- Relay: `CLIENT_ID`/`CLIENT_SECRET` read from environment only
|
- Relay: `CLIENT_ID`/`CLIENT_SECRET` read from environment only
|
||||||
(`app/main.py:31-32`); `.env` is gitignored; `__pycache__`/`*.pyc`
|
(`app/main.py:31-32`); `.env` is gitignored; `__pycache__`/`*.pyc`
|
||||||
untracked; `.env.example` ships empty placeholders.
|
untracked; `.env.example` ships empty placeholders.
|
||||||
- Binary scan: only the relay IP, `maps.apple.com`, and `group.com.apt.fuelboard`
|
- Binary scan should show no private LAN data endpoint; expected network strings
|
||||||
strings found. No credentials.
|
include public GitHub raw URLs, `maps.apple.com`, and `group.com.apt.fuelboard`.
|
||||||
|
No credentials.
|
||||||
|
|
||||||
### 3. Price data hardening — PASS (three layers)
|
### 3. Price data hardening — PASS (three layers)
|
||||||
|
|
||||||
@@ -53,17 +51,19 @@ the govuk-API switch (BACKLOG P0). Findings below are ordered by severity.
|
|||||||
- `kSecAttrAccessibleAfterFirstUnlock` — widget can read after reboot without
|
- `kSecAttrAccessibleAfterFirstUnlock` — widget can read after reboot without
|
||||||
unlock. Correct for background widget refresh.
|
unlock. Correct for background widget refresh.
|
||||||
|
|
||||||
### 5. Transport security — PASS for LAN dev, MUST CHANGE for public
|
### 5. Transport security — PASS
|
||||||
|
|
||||||
- No `NSAllowsArbitraryLoads` anywhere (confirmed by plist + pbxproj grep).
|
- No `NSAllowsArbitraryLoads` anywhere (confirmed by plist + pbxproj grep).
|
||||||
- Cleartext HTTP only to the LAN relay IP (ATS-exempt). Once the relay moves
|
- No cleartext LAN relay endpoint ships in the app binary. Live network data is
|
||||||
behind an HTTPS domain (govuk switch), no ATS changes are needed.
|
fetched over HTTPS from the public GitHub mirror.
|
||||||
|
|
||||||
### 6. Privacy / permissions — review for App Store
|
### 6. Privacy / permissions — review for App Store
|
||||||
|
|
||||||
- `NSLocationWhenInUseUsageDescription` + `NSLocationAlwaysAndWhenInUseUsageDescription`
|
- `NSLocationWhenInUseUsageDescription` + `NSLocationAlwaysAndWhenInUseUsageDescription`
|
||||||
both present with clear copy; `UIBackgroundModes: location` justified by the
|
both present with clear copy. The app deliberately does not include the
|
||||||
alert feature (geofenced cheapest-station notifications).
|
`location` value in `UIBackgroundModes`; optional alerts use CoreLocation
|
||||||
|
region monitoring/significant-change behaviour rather than persistent
|
||||||
|
real-time background tracking.
|
||||||
- Location is the only sensitive permission requested. No contacts, photos,
|
- Location is the only sensitive permission requested. No contacts, photos,
|
||||||
camera, mic.
|
camera, mic.
|
||||||
- No third-party SDKs → no SDK privacy manifests required.
|
- No third-party SDKs → no SDK privacy manifests required.
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
// in Settings → Live Activity (INDEPENDENT of the Stations-tab selection).
|
// in Settings → Live Activity (INDEPENDENT of the Stations-tab selection).
|
||||||
// Everything dynamic lives in ContentState so updates — including a fuel
|
// Everything dynamic lives in ContentState so updates — including a fuel
|
||||||
// change — apply to the running activity without needing to restart it.
|
// change — apply to the running activity without needing to restart it.
|
||||||
// Tapping the activity opens Apple Maps driving directions (maps://).
|
// Tapping the activity honours Settings → Tap station: Open map opens Apple
|
||||||
|
// Maps driving directions (maps://), More info opens the detail sheet in
|
||||||
|
// the app (fuelboard://station).
|
||||||
|
|
||||||
import ActivityKit
|
import ActivityKit
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -30,6 +32,12 @@ struct FuelBoardLiveActivityAttributes: ActivityAttributes {
|
|||||||
/// re-reading storage that may not be shared on sideloads.
|
/// re-reading storage that may not be shared on sideloads.
|
||||||
var priceDisplayStyle: PriceDisplayStyle
|
var priceDisplayStyle: PriceDisplayStyle
|
||||||
var distanceKM: Double
|
var distanceKM: Double
|
||||||
|
/// Whether `distanceKM` came from the straight-line fallback instead of a
|
||||||
|
/// routed road distance, so the extension renders the "Approx." marker
|
||||||
|
/// without re-reading storage the extension may not share.
|
||||||
|
/// Optional so a state pushed by an earlier build still decodes — nil
|
||||||
|
/// reads as "not marked", i.e. the previous behaviour.
|
||||||
|
var distanceIsApproximate: Bool?
|
||||||
var lat: Double
|
var lat: Double
|
||||||
var lng: Double
|
var lng: Double
|
||||||
var updatedAt: Date
|
var updatedAt: Date
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// FuelHistory.swift — price-history store for the Favourites Trends graph.
|
// FuelHistory.swift — price-history store for the Favourites Trends graph.
|
||||||
//
|
//
|
||||||
// Reads the GitHub price mirror (aptonline/fuelboard-data, pushed daily by the
|
// Reads the GitHub price mirror (aptonline/fuelboard-data, pushed daily by the
|
||||||
// LAN relay): `latest.json` pointer + `history/YYYY-MM-DD.json` full dumps.
|
// ingestion relay): `latest.json` pointer + `history/YYYY-MM-DD.json` full dumps.
|
||||||
// The store keeps a favourites-only day cache in the app group so the chart
|
// The store keeps a favourites-only day cache in the app group so the chart
|
||||||
// works offline once a day has been seen, and only fetches the days it is
|
// works offline once a day has been seen, and only fetches the days it is
|
||||||
// missing. Foundation-only so the unit-test target can compile it on macOS.
|
// missing. Foundation-only so the unit-test target can compile it on macOS.
|
||||||
@@ -66,8 +66,8 @@ enum FuelHistoryError: LocalizedError {
|
|||||||
// MARK: - Store
|
// MARK: - Store
|
||||||
|
|
||||||
enum FuelHistoryStore {
|
enum FuelHistoryStore {
|
||||||
/// GitHub raw mirror base — the app reads history directly from here;
|
/// GitHub raw mirror base — the app reads history and live data directly from here.
|
||||||
/// the LAN relay remains the live-data fallback.
|
/// No private LAN relay fallback ships in the app binary.
|
||||||
static let mirrorBase = URL(string: "https://raw.githubusercontent.com/aptonline/fuelboard-data/main")!
|
static let mirrorBase = URL(string: "https://raw.githubusercontent.com/aptonline/fuelboard-data/main")!
|
||||||
static let historyCacheKey = "fuelboard.historyCache"
|
static let historyCacheKey = "fuelboard.historyCache"
|
||||||
static let maxCachedDays = 90
|
static let maxCachedDays = 90
|
||||||
|
|||||||
@@ -19,14 +19,15 @@ protocol FuelPriceProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum FuelPriceProvider {
|
enum FuelPriceProvider {
|
||||||
/// Default: the live chain — GitHub mirror (primary, off-LAN) → LAN
|
/// Default: the live chain — public GitHub mirror (primary, off-LAN) →
|
||||||
/// relay (fallback) → bundled dump (last resort in the app target).
|
/// bundled dump/cache fallback in callers. No private LAN fallback ships in
|
||||||
|
/// the app binary.
|
||||||
static let active: FuelPriceProviding = LiveChainProvider()
|
static let active: FuelPriceProviding = LiveChainProvider()
|
||||||
|
|
||||||
/// Decodes a relay payload into stations, applying the defensive price
|
/// Decodes a mirror payload into stations, applying the defensive price
|
||||||
/// band. Internal so the unit-test target can exercise the guard.
|
/// band. Internal so the unit-test target can exercise the guard.
|
||||||
static func decodeStations(from data: Data) throws -> [FuelStation] {
|
static func decodeStations(from data: Data) throws -> [FuelStation] {
|
||||||
let payload = try JSONDecoder().decode(RelayResponse.self, from: data)
|
let payload = try JSONDecoder().decode(MirrorResponse.self, from: data)
|
||||||
return payload.stations.map { relay in
|
return payload.stations.map { relay in
|
||||||
FuelStation(
|
FuelStation(
|
||||||
id: relay.id ?? relay.name ?? UUID().uuidString,
|
id: relay.id ?? relay.name ?? UUID().uuidString,
|
||||||
@@ -42,13 +43,13 @@ enum FuelPriceProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Price sanity band (pence/litre) — anything outside is relay regression
|
/// Price sanity band (pence/litre) — anything outside is mirror regression
|
||||||
/// garbage and must never reach calculations (shared by the live decode
|
/// garbage and must never reach calculations (shared by the live decode
|
||||||
/// and the history mirror decoder).
|
/// and the history mirror decoder).
|
||||||
static let priceBand: ClosedRange<Double> = 50...500
|
static let priceBand: ClosedRange<Double> = 50...500
|
||||||
|
|
||||||
/// Maps relay grade keys (E5/E10/DIESEL…) to FuelType with the defensive
|
/// Maps mirror grade keys (E5/E10/DIESEL…) to FuelType with the defensive
|
||||||
/// band guard. Shared by the relay decode and the history mirror parser so
|
/// band guard. Shared by the mirror decode and the history mirror parser so
|
||||||
/// both surfaces apply identical sanitisation.
|
/// both surfaces apply identical sanitisation.
|
||||||
static func mapGrades(_ prices: [String: Double]) -> [FuelType: Double] {
|
static func mapGrades(_ prices: [String: Double]) -> [FuelType: Double] {
|
||||||
var result: [FuelType: Double] = [:]
|
var result: [FuelType: Double] = [:]
|
||||||
@@ -64,12 +65,12 @@ enum FuelPriceProvider {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes the relay envelope metadata (source, dataset update time) — the
|
/// Decodes mirror envelope metadata (source, dataset update time) — the
|
||||||
/// About section shows these so the user can see which data source is
|
/// About section shows these so the user can see which data source is
|
||||||
/// live and how fresh the GOV.UK data itself is. The fields are additive
|
/// live and how fresh the GOV.UK data itself is. The fields are additive
|
||||||
/// on the relay, so older relays simply return nil values.
|
/// on the mirror, so older snapshots simply return nil values.
|
||||||
static func decodeRelayMeta(from data: Data) -> RelayMeta? {
|
static func decodeRelayMeta(from data: Data) -> RelayMeta? {
|
||||||
guard let payload = try? JSONDecoder().decode(RelayResponse.self, from: data) else {
|
guard let payload = try? JSONDecoder().decode(MirrorResponse.self, from: data) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return RelayMeta(
|
return RelayMeta(
|
||||||
@@ -80,64 +81,11 @@ enum FuelPriceProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Relay provider (default)
|
// MARK: - Mirror payload envelope
|
||||||
|
|
||||||
/// Talks to the FuelBoard Relay (keyless proxy). The relay serves the FULL-UK
|
private struct MirrorResponse: Codable {
|
||||||
/// Fuel Finder dataset — every station in England, Wales, Scotland, NI — and
|
|
||||||
/// filters/sorts server-side. No credentials in the app.
|
|
||||||
struct RelayFuelProvider: FuelPriceProviding {
|
|
||||||
var baseURL = URL(string: "http://192.168.1.131:8789")!
|
|
||||||
|
|
||||||
/// Envelope metadata from the most recent successful relay fetch —
|
|
||||||
/// consumed by Settings → About (source api/csv, station count, GOV.UK
|
|
||||||
/// dataset update time). Written by fetchStations, read by the view.
|
|
||||||
static var latestMeta: RelayMeta?
|
|
||||||
|
|
||||||
/// Admin telemetry: the relay tags inbound connections by client kind
|
|
||||||
/// (app vs widget) so the developer can monitor the poll model. Siri
|
|
||||||
/// never fetches (cached-data-first). Purely observational — the relay
|
|
||||||
/// ignores the header for responses.
|
|
||||||
static let clientTagHeader = "X-Client"
|
|
||||||
|
|
||||||
static func relayRequest(_ url: URL, client: String, timeout: TimeInterval = 30) -> URLRequest {
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
request.timeoutInterval = timeout
|
|
||||||
request.setValue(client, forHTTPHeaderField: clientTagHeader)
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
|
||||||
var components = URLComponents(url: baseURL.appendingPathComponent("api/v1/stations"), resolvingAgainstBaseURL: false)!
|
|
||||||
var query: [URLQueryItem] = [URLQueryItem(name: "fuel", value: fuel.rawValue)]
|
|
||||||
if let lat, let lng {
|
|
||||||
query.append(URLQueryItem(name: "lat", value: String(lat)))
|
|
||||||
query.append(URLQueryItem(name: "lng", value: String(lng)))
|
|
||||||
}
|
|
||||||
if let radiusKM {
|
|
||||||
// Focused fetch (alert path) — small response, server-side radius.
|
|
||||||
query.append(URLQueryItem(name: "radius", value: String(radiusKM)))
|
|
||||||
query.append(URLQueryItem(name: "limit", value: "500"))
|
|
||||||
} else {
|
|
||||||
// Full-UK dump: NO radius — the device filters locally
|
|
||||||
// (5/10/15-mile radius, fuel, sort). 10,000 covers the dataset.
|
|
||||||
query.append(URLQueryItem(name: "limit", value: "10000"))
|
|
||||||
}
|
|
||||||
components.queryItems = query
|
|
||||||
|
|
||||||
let request = RelayFuelProvider.relayRequest(components.url!, client: "app")
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
|
||||||
throw FuelProviderError.relayUnavailable
|
|
||||||
}
|
|
||||||
let stations = try FuelPriceProvider.decodeStations(from: data)
|
|
||||||
Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data)
|
|
||||||
return stations
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct RelayResponse: Codable {
|
|
||||||
/// Envelope metadata surfaced in Settings → About so the user can see
|
/// Envelope metadata surfaced in Settings → About so the user can see
|
||||||
/// which relay source is live and how fresh the GOV.UK data is.
|
/// which mirror source is live and how fresh the GOV.UK data is.
|
||||||
let source: String?
|
let source: String?
|
||||||
let stationsCount: Int?
|
let stationsCount: Int?
|
||||||
let dataUpdated: String?
|
let dataUpdated: String?
|
||||||
@@ -148,7 +96,7 @@ private struct RelayResponse: Codable {
|
|||||||
case dataUpdated = "data_updated"
|
case dataUpdated = "data_updated"
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RelayStation: Codable {
|
struct MirrorStation: Codable {
|
||||||
let id: String?
|
let id: String?
|
||||||
let name: String?
|
let name: String?
|
||||||
let brand: String?
|
let brand: String?
|
||||||
@@ -159,24 +107,24 @@ private struct RelayResponse: Codable {
|
|||||||
let price: Double?
|
let price: Double?
|
||||||
let prices: [String: Double]?
|
let prices: [String: Double]?
|
||||||
|
|
||||||
/// Map relay grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the
|
/// Map mirror grade keys (E5/E10/DIESEL) to FuelType for ALL fuels the
|
||||||
/// station sells — one fetch populates every fuel tab. Defensive guard:
|
/// station sells — one fetch populates every fuel tab. Defensive guard:
|
||||||
/// any grade outside the 50–500 pence/litre band is dropped, so a relay
|
/// any grade outside the 50–500 pence/litre band is dropped, so a mirror
|
||||||
/// regression (e.g. the band being removed server-side) can't re-poison
|
/// regression (e.g. the band being removed upstream) can't re-poison
|
||||||
/// the nationwide cheapest reference with 1.3p / 1589p garbage.
|
/// the nationwide cheapest reference with 1.3p / 1589p garbage.
|
||||||
var allPrices: [FuelType: Double] {
|
var allPrices: [FuelType: Double] {
|
||||||
var result = FuelPriceProvider.mapGrades(prices ?? [:])
|
var result = FuelPriceProvider.mapGrades(prices ?? [:])
|
||||||
// Backwards-compat: relay versions without `prices` still send `price`.
|
// Backwards-compat: mirror versions without `prices` still send `price`.
|
||||||
if result.isEmpty, let price, FuelPriceProvider.priceBand.contains(price) {
|
if result.isEmpty, let price, FuelPriceProvider.priceBand.contains(price) {
|
||||||
result[.e10] = price
|
result[.e10] = price
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let stations: [RelayStation]
|
let stations: [MirrorStation]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Envelope metadata from the relay response — which data source is live
|
/// Envelope metadata from the mirror response — which data source is live
|
||||||
/// (api/csv) and the freshest price-update timestamp the GOV.UK server
|
/// (api/csv) and the freshest price-update timestamp the GOV.UK server
|
||||||
/// itself reports for the dataset. Displayed in Settings → About.
|
/// itself reports for the dataset. Displayed in Settings → About.
|
||||||
struct RelayMeta: Codable, Equatable {
|
struct RelayMeta: Codable, Equatable {
|
||||||
@@ -189,7 +137,7 @@ struct RelayMeta: Codable, Equatable {
|
|||||||
|
|
||||||
/// Representative stations spread across ENGLAND (one cluster per region) so
|
/// Representative stations spread across ENGLAND (one cluster per region) so
|
||||||
/// the fallback works anywhere in the country — it is NOT tied to one town.
|
/// the fallback works anywhere in the country — it is NOT tied to one town.
|
||||||
/// Real UK-wide data comes from the relay (full-UK Fuel Finder CSV/API).
|
/// Real UK-wide data comes from the public mirror (full-UK Fuel Finder CSV/API).
|
||||||
/// Prices in pence/litre.
|
/// Prices in pence/litre.
|
||||||
struct SampleFuelProvider: FuelPriceProviding {
|
struct SampleFuelProvider: FuelPriceProviding {
|
||||||
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
||||||
@@ -302,7 +250,7 @@ enum FuelProviderError: LocalizedError {
|
|||||||
case .notImplemented:
|
case .notImplemented:
|
||||||
return "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials."
|
return "Live Fuel Finder API not wired yet — activate with GOV.UK One Login credentials."
|
||||||
case .relayUnavailable:
|
case .relayUnavailable:
|
||||||
return "FuelBoard Relay unreachable — showing cached/sample data."
|
return "FuelBoard live data unavailable — showing cached/sample data."
|
||||||
case .mirrorUnavailable:
|
case .mirrorUnavailable:
|
||||||
return "GitHub price mirror unreachable."
|
return "GitHub price mirror unreachable."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,17 +89,21 @@ struct FuelPriceWidgetContent: View {
|
|||||||
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
FuelStore.priceTextAttributed(price, size: 26, weight: .bold, color: .green)
|
||||||
}
|
}
|
||||||
if let location = entry.location {
|
if let location = entry.location {
|
||||||
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)) + " away")
|
let distance = FuelStore.stationDistanceDisplay(
|
||||||
|
station: station, userLat: location.lat, userLng: location.lng
|
||||||
|
)
|
||||||
|
Text(distance.label(unit: entry.unit) + " away")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text("Tap for directions")
|
Text(FuelStore.loadStationTapAction() == .showDetails
|
||||||
|
? "Tap for details" : "Tap for directions")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||||
.modifier(WidgetURLModifier(url: isWidget ? station.widgetDirectionsURL : nil))
|
.modifier(WidgetURLModifier(url: isWidget ? station.tapDestinationURL : nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many station rows each family can fit: medium widgets are short
|
/// How many station rows each family can fit: medium widgets are short
|
||||||
@@ -129,7 +133,7 @@ struct FuelPriceWidgetContent: View {
|
|||||||
}
|
}
|
||||||
ForEach(entry.stations.prefix(maxRows)) { station in
|
ForEach(entry.stations.prefix(maxRows)) { station in
|
||||||
if isWidget {
|
if isWidget {
|
||||||
Link(destination: station.widgetDirectionsURL ?? URL(string: "maps://")!) {
|
Link(destination: station.tapDestinationURL ?? URL(string: "maps://")!) {
|
||||||
row(station, cheapest: cheapest)
|
row(station, cheapest: cheapest)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -148,7 +152,10 @@ struct FuelPriceWidgetContent: View {
|
|||||||
.font(.caption.weight(.semibold))
|
.font(.caption.weight(.semibold))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if let location = entry.location {
|
if let location = entry.location {
|
||||||
Text(entry.unit.format(FuelStore.displayDistanceKM(station: station, userLat: location.lat, userLng: location.lng)))
|
let distance = FuelStore.stationDistanceDisplay(
|
||||||
|
station: station, userLat: location.lat, userLng: location.lng
|
||||||
|
)
|
||||||
|
Text(distance.label(unit: entry.unit))
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,605 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum FuelSavingsFillUnit: String, Codable, CaseIterable, Identifiable {
|
||||||
|
case pounds
|
||||||
|
case litres
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .pounds: return "£"
|
||||||
|
case .litres: return "Litres"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FuelSavingsFillAmount: Codable, Equatable {
|
||||||
|
var value: Double
|
||||||
|
var unit: FuelSavingsFillUnit
|
||||||
|
|
||||||
|
var isValid: Bool {
|
||||||
|
value.isFinite && value > 0 && value <= FuelSavingsCalculator.maximumFillAmount(for: unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FuelSavingsSettings: Codable, Equatable {
|
||||||
|
var enabled: Bool
|
||||||
|
var mpg: Double
|
||||||
|
var preferredAmount: FuelSavingsFillAmount
|
||||||
|
|
||||||
|
static let defaultMPG: Double = 45
|
||||||
|
static let defaultPreferredAmount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||||
|
|
||||||
|
static var defaults: FuelSavingsSettings {
|
||||||
|
FuelSavingsSettings(enabled: false, mpg: defaultMPG, preferredAmount: defaultPreferredAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isConfigured: Bool {
|
||||||
|
enabled && FuelSavingsCalculator.isValidMPG(mpg) && preferredAmount.isValid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FuelSavingsStatus: String, Codable, Equatable {
|
||||||
|
case worthTheDrive
|
||||||
|
case breakEven
|
||||||
|
case notWorthTheDrive
|
||||||
|
/// The comparison baseline itself — the station every other saving is
|
||||||
|
/// measured against. Shown as "Closest station", never "Break even".
|
||||||
|
case baseline
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .worthTheDrive: return "Worth the drive"
|
||||||
|
case .breakEven: return "Break even"
|
||||||
|
case .notWorthTheDrive: return "Not worth the drive"
|
||||||
|
case .baseline: return FuelSavingsResult.baselineHeadline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How the journey distances behind an estimate were obtained.
|
||||||
|
enum FuelSavingsDistanceBasis: String, Codable, Equatable {
|
||||||
|
/// Measured road route, out and back, from the routing service.
|
||||||
|
case roadRoute
|
||||||
|
/// Straight-line out-and-back estimate, used until a route is measured.
|
||||||
|
case straightLine
|
||||||
|
|
||||||
|
var isEstimate: Bool { self == .straightLine }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a Best Value comparison is backed by complete road-route data.
|
||||||
|
enum JourneyConfidence: String, Codable, Equatable {
|
||||||
|
/// Both baseline and candidate have measured outward and return road routes.
|
||||||
|
case confirmed
|
||||||
|
/// At least one journey is straight-line, missing, failed, or using a legacy
|
||||||
|
/// one-way road cache whose return leg is inferred rather than measured.
|
||||||
|
case estimated
|
||||||
|
|
||||||
|
var isEstimated: Bool { self == .estimated }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FuelSavingsResult: Codable, Equatable {
|
||||||
|
let fillLitres: Double
|
||||||
|
let grossSavingGBP: Double
|
||||||
|
let extraDrivingCostGBP: Double
|
||||||
|
let extraDistanceMiles: Double
|
||||||
|
let netSavingGBP: Double
|
||||||
|
let status: FuelSavingsStatus
|
||||||
|
let distanceBasis: FuelSavingsDistanceBasis
|
||||||
|
let journeyConfidence: JourneyConfidence
|
||||||
|
|
||||||
|
init(fillLitres: Double,
|
||||||
|
grossSavingGBP: Double,
|
||||||
|
extraDrivingCostGBP: Double,
|
||||||
|
extraDistanceMiles: Double,
|
||||||
|
netSavingGBP: Double,
|
||||||
|
status: FuelSavingsStatus,
|
||||||
|
distanceBasis: FuelSavingsDistanceBasis = .roadRoute,
|
||||||
|
journeyConfidence: JourneyConfidence? = nil) {
|
||||||
|
self.fillLitres = fillLitres
|
||||||
|
self.grossSavingGBP = grossSavingGBP
|
||||||
|
self.extraDrivingCostGBP = extraDrivingCostGBP
|
||||||
|
self.extraDistanceMiles = extraDistanceMiles
|
||||||
|
self.netSavingGBP = netSavingGBP
|
||||||
|
self.status = status
|
||||||
|
self.distanceBasis = distanceBasis
|
||||||
|
self.journeyConfidence = journeyConfidence ?? (distanceBasis.isEstimate ? .estimated : .confirmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Money formatting for savings strings, in one place so the exact wording the
|
||||||
|
/// UI shows is testable.
|
||||||
|
enum FuelSavingsFormat {
|
||||||
|
static func money(_ value: Double) -> String {
|
||||||
|
String(format: "£%.2f", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A distance stored in MILES, formatted in the display unit. The figures
|
||||||
|
/// behind an estimate are stored in miles whatever the user's unit, so this
|
||||||
|
/// is the one place that conversion lives.
|
||||||
|
static func distance(_ miles: Double, unit: DistanceUnit) -> String {
|
||||||
|
// Convert miles→km FIRST, then let the unit format it. `unit.toKM` would
|
||||||
|
// convert by the CURRENT unit instead, leaving a km user's figure
|
||||||
|
// unlabelled and 1.6× too large.
|
||||||
|
unit.format(miles * FuelSavingsCalculator.kilometresPerMile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row label for the extra driving a saving has to beat. The stored figure is
|
||||||
|
/// always MILES while the row's distance line is one-way, so the wording names
|
||||||
|
/// the RETURN journey: the figure is an out-and-back DELTA between two
|
||||||
|
/// journeys and can legitimately exceed the one-way distance, which read as a
|
||||||
|
/// contradiction when it said "more driving than closest".
|
||||||
|
static func extraTravelLabel(extraDistanceMiles: Double,
|
||||||
|
unit: DistanceUnit,
|
||||||
|
confidence: JourneyConfidence = .confirmed) -> String? {
|
||||||
|
guard extraDistanceMiles > 0.05 else { return nil }
|
||||||
|
let distanceText = distance(extraDistanceMiles, unit: unit)
|
||||||
|
return confidence == .estimated
|
||||||
|
? "Approx. \(distanceText) extra return travel"
|
||||||
|
: "\(distanceText) extra return travel"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VoiceOver form of `extraTravelLabel`. The abbreviation and the unit suffix
|
||||||
|
/// are spoken as words ("Approximately 6.4 miles extra return travel") because
|
||||||
|
/// synthesizers render "Approx." and "mi" inconsistently.
|
||||||
|
static func extraTravelSpokenLabel(extraDistanceMiles: Double,
|
||||||
|
unit: DistanceUnit,
|
||||||
|
confidence: JourneyConfidence = .confirmed) -> String? {
|
||||||
|
guard extraDistanceMiles > 0.05 else { return nil }
|
||||||
|
let distanceText = spokenDistance(extraDistanceMiles, unit: unit)
|
||||||
|
return confidence == .estimated
|
||||||
|
? "Approximately \(distanceText) extra return travel"
|
||||||
|
: "\(distanceText) extra return travel"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A distance stored in MILES, spoken: "6.4 miles" / "10.3 kilometres". The
|
||||||
|
/// miles→km hop is deliberate: the stored figure is miles while
|
||||||
|
/// `DistanceUnit.spoken` speaks a KM value, the same boundary `distance(_:unit:)`
|
||||||
|
/// crosses.
|
||||||
|
static func spokenDistance(_ miles: Double, unit: DistanceUnit) -> String {
|
||||||
|
unit.spoken(miles * FuelSavingsCalculator.kilometresPerMile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FuelSavingsResult {
|
||||||
|
/// Baseline row label — required wording.
|
||||||
|
static let baselineHeadline = "Closest station"
|
||||||
|
/// Neutral result label — required wording.
|
||||||
|
static let breakEvenHeadline = "Break even"
|
||||||
|
|
||||||
|
/// The same estimate relabelled as the comparison baseline: nothing to save
|
||||||
|
/// or lose against itself, and no extra travel.
|
||||||
|
func asBaseline() -> FuelSavingsResult {
|
||||||
|
FuelSavingsResult(
|
||||||
|
fillLitres: fillLitres,
|
||||||
|
grossSavingGBP: 0,
|
||||||
|
extraDrivingCostGBP: 0,
|
||||||
|
extraDistanceMiles: 0,
|
||||||
|
netSavingGBP: 0,
|
||||||
|
status: .baseline,
|
||||||
|
distanceBasis: distanceBasis,
|
||||||
|
journeyConfidence: journeyConfidence
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row headline, e.g. "£1.22 better off after travel", "£0.31 worse off after
|
||||||
|
/// travel", "Break even", "Closest station".
|
||||||
|
///
|
||||||
|
/// Estimated comparisons are prefixed "Approx. " for positive/negative money
|
||||||
|
/// results — the WORD, never a "~": a tilde reads as decoration (and assistive
|
||||||
|
/// tech may skip it) instead of saying "this is an estimate". Break-even and
|
||||||
|
/// baseline stay plain words, with the estimate context carried by the
|
||||||
|
/// extra-distance caption or the detail explanation.
|
||||||
|
func headline(compact: Bool = false) -> String {
|
||||||
|
let prefix = journeyConfidence.isEstimated ? "Approx. " : ""
|
||||||
|
switch status {
|
||||||
|
case .baseline:
|
||||||
|
return Self.baselineHeadline
|
||||||
|
case .breakEven:
|
||||||
|
return Self.breakEvenHeadline
|
||||||
|
case .worthTheDrive:
|
||||||
|
return "\(prefix)\(FuelSavingsFormat.money(netSavingGBP)) better off after travel"
|
||||||
|
case .notWorthTheDrive:
|
||||||
|
return "\(prefix)\(FuelSavingsFormat.money(abs(netSavingGBP))) worse off after travel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VoiceOver form of `headline()`: "Approx." is spelled out so a synthesizer
|
||||||
|
/// cannot read the abbreviation as "approx" or drop it entirely.
|
||||||
|
func spokenHeadline() -> String {
|
||||||
|
headline().replacingOccurrences(of: "Approx. ", with: "Approximately ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row capsule copy: the headline, plus what the baseline row IS. Without the
|
||||||
|
/// suffix the baseline capsule just repeated the status word above it
|
||||||
|
/// ("Closest station" twice) and said nothing about the saving being measured
|
||||||
|
/// against it.
|
||||||
|
func capsuleHeadline() -> String {
|
||||||
|
let headline = headline(compact: true)
|
||||||
|
return status == .baseline ? "\(headline) · your baseline" : headline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One station's journey distance, plus how it was obtained.
|
||||||
|
struct FuelSavingsJourney: Equatable {
|
||||||
|
let miles: Double
|
||||||
|
let basis: FuelSavingsDistanceBasis
|
||||||
|
let confidence: JourneyConfidence
|
||||||
|
|
||||||
|
init(miles: Double,
|
||||||
|
basis: FuelSavingsDistanceBasis,
|
||||||
|
confidence: JourneyConfidence? = nil) {
|
||||||
|
self.miles = miles
|
||||||
|
self.basis = basis
|
||||||
|
self.confidence = confidence ?? (basis.isEstimate ? .estimated : .confirmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FuelSavingsAvailability: Equatable {
|
||||||
|
case available(FuelSavingsResult)
|
||||||
|
case unavailable(String)
|
||||||
|
|
||||||
|
var result: FuelSavingsResult? {
|
||||||
|
if case .available(let result) = self { return result }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FuelSavingsRankedStation: Equatable {
|
||||||
|
let stationID: String
|
||||||
|
let result: FuelSavingsAvailability
|
||||||
|
let distanceMiles: Double
|
||||||
|
let pricePencePerLitre: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BestValueAlertCandidate: Equatable {
|
||||||
|
let station: FuelStation
|
||||||
|
let result: FuelSavingsResult
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FuelSavingsError: Error, Equatable {
|
||||||
|
case invalidMPG
|
||||||
|
case invalidAmount
|
||||||
|
case invalidPrice
|
||||||
|
case invalidDistance
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FuelSavingsCalculator {
|
||||||
|
/// 1 UK (imperial) gallon. Must never be the US gallon (3.785411784 L):
|
||||||
|
/// UK pumps are priced per litre and drivers quote UK MPG, so a US gallon
|
||||||
|
/// would understate every travel cost by 17%.
|
||||||
|
static let imperialGallonLitres = 4.54609
|
||||||
|
/// Exact mile↔kilometre factors (1 mile = 1.609344 km). Every figure the
|
||||||
|
/// savings maths keeps is in MILES, and every displayed or fallback
|
||||||
|
/// distance converts through these, so one quantity is never converted with
|
||||||
|
/// two different constants. The audit found 1.60934, 1609.34 and 0.621371
|
||||||
|
/// mixed in the same path — a ~2 parts-per-million difference, immaterial to
|
||||||
|
/// the money, but three constants where there should be one.
|
||||||
|
static let kilometresPerMile = 1.609344
|
||||||
|
static let metresPerMile = 1609.344
|
||||||
|
static let breakEvenToleranceGBP = 0.05
|
||||||
|
static let minimumMPG: Double = 5
|
||||||
|
static let maximumMPG: Double = 200
|
||||||
|
|
||||||
|
static func maximumFillAmount(for unit: FuelSavingsFillUnit) -> Double {
|
||||||
|
switch unit {
|
||||||
|
case .pounds: return 500
|
||||||
|
case .litres: return 250
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounds for every fill-up SLIDER (Settings and the in-list adjustment
|
||||||
|
/// share them, so the two controls can never offer different ranges). The
|
||||||
|
/// validity ceiling above is deliberately wider, so a stored outlier is
|
||||||
|
/// displayed rather than destroyed by the control that shows it.
|
||||||
|
static func fillAmountSliderRange(for unit: FuelSavingsFillUnit) -> ClosedRange<Double> {
|
||||||
|
switch unit {
|
||||||
|
case .pounds: return 10...100
|
||||||
|
case .litres: return 10...80
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fillAmountSliderStep(for unit: FuelSavingsFillUnit) -> Double {
|
||||||
|
switch unit {
|
||||||
|
case .pounds: return 5
|
||||||
|
case .litres: return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounds for the average-MPG slider. The validity bounds (5...200) are
|
||||||
|
/// wide enough for any stored figure but far too wide to fine-tune by
|
||||||
|
/// touch; every real-world UK car sits inside this range.
|
||||||
|
static let mpgSliderRange: ClosedRange<Double> = 10...100
|
||||||
|
static let mpgSliderStep: Double = 1
|
||||||
|
|
||||||
|
static func isValidMPG(_ mpg: Double) -> Bool {
|
||||||
|
mpg.isFinite && mpg >= minimumMPG && mpg <= maximumMPG
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fuelUsedLitres(distanceMiles: Double, mpg: Double) -> Double {
|
||||||
|
distanceMiles / mpg * imperialGallonLitres
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fillLitres(amount: FuelSavingsFillAmount, baselinePricePencePerLitre: Double) throws -> Double {
|
||||||
|
guard amount.isValid else { throw FuelSavingsError.invalidAmount }
|
||||||
|
switch amount.unit {
|
||||||
|
case .litres:
|
||||||
|
return amount.value
|
||||||
|
case .pounds:
|
||||||
|
guard baselinePricePencePerLitre.isFinite, baselinePricePencePerLitre > 0 else {
|
||||||
|
throw FuelSavingsError.invalidPrice
|
||||||
|
}
|
||||||
|
return amount.value / (baselinePricePencePerLitre / 100.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func convert(amount: FuelSavingsFillAmount,
|
||||||
|
to unit: FuelSavingsFillUnit,
|
||||||
|
referencePricePencePerLitre: Double) throws -> FuelSavingsFillAmount {
|
||||||
|
guard referencePricePencePerLitre.isFinite, referencePricePencePerLitre > 0 else {
|
||||||
|
throw FuelSavingsError.invalidPrice
|
||||||
|
}
|
||||||
|
guard amount.isValid else { throw FuelSavingsError.invalidAmount }
|
||||||
|
guard amount.unit != unit else { return amount }
|
||||||
|
switch (amount.unit, unit) {
|
||||||
|
case (.pounds, .litres):
|
||||||
|
return FuelSavingsFillAmount(
|
||||||
|
value: amount.value / (referencePricePencePerLitre / 100.0),
|
||||||
|
unit: .litres
|
||||||
|
)
|
||||||
|
case (.litres, .pounds):
|
||||||
|
return FuelSavingsFillAmount(
|
||||||
|
value: amount.value * (referencePricePencePerLitre / 100.0),
|
||||||
|
unit: .pounds
|
||||||
|
)
|
||||||
|
case (.pounds, .pounds), (.litres, .litres):
|
||||||
|
return amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func calculate(mpg: Double,
|
||||||
|
amount: FuelSavingsFillAmount,
|
||||||
|
baselinePricePencePerLitre: Double,
|
||||||
|
candidatePricePencePerLitre: Double,
|
||||||
|
baselineJourneyMiles: Double,
|
||||||
|
candidateJourneyMiles: Double,
|
||||||
|
distanceBasis: FuelSavingsDistanceBasis = .roadRoute,
|
||||||
|
journeyConfidence: JourneyConfidence? = nil,
|
||||||
|
breakEvenToleranceGBP: Double = Self.breakEvenToleranceGBP) throws -> FuelSavingsResult {
|
||||||
|
guard isValidMPG(mpg) else { throw FuelSavingsError.invalidMPG }
|
||||||
|
guard baselinePricePencePerLitre.isFinite, baselinePricePencePerLitre > 0,
|
||||||
|
candidatePricePencePerLitre.isFinite, candidatePricePencePerLitre > 0 else {
|
||||||
|
throw FuelSavingsError.invalidPrice
|
||||||
|
}
|
||||||
|
guard baselineJourneyMiles.isFinite, candidateJourneyMiles.isFinite,
|
||||||
|
baselineJourneyMiles >= 0, candidateJourneyMiles >= 0 else {
|
||||||
|
throw FuelSavingsError.invalidDistance
|
||||||
|
}
|
||||||
|
let fillLitres = try fillLitres(amount: amount, baselinePricePencePerLitre: baselinePricePencePerLitre)
|
||||||
|
let baselinePriceGBP = baselinePricePencePerLitre / 100.0
|
||||||
|
let candidatePriceGBP = candidatePricePencePerLitre / 100.0
|
||||||
|
let grossSaving = fillLitres * (baselinePriceGBP - candidatePriceGBP)
|
||||||
|
let extraDistance = max(0, candidateJourneyMiles - baselineJourneyMiles)
|
||||||
|
let extraFuelLitres = fuelUsedLitres(distanceMiles: extraDistance, mpg: mpg)
|
||||||
|
let extraFuelCost = extraFuelLitres * candidatePriceGBP
|
||||||
|
let netSaving = grossSaving - extraFuelCost
|
||||||
|
let status: FuelSavingsStatus
|
||||||
|
if netSaving > breakEvenToleranceGBP {
|
||||||
|
status = .worthTheDrive
|
||||||
|
} else if netSaving < -breakEvenToleranceGBP {
|
||||||
|
status = .notWorthTheDrive
|
||||||
|
} else {
|
||||||
|
status = .breakEven
|
||||||
|
}
|
||||||
|
return FuelSavingsResult(
|
||||||
|
fillLitres: fillLitres,
|
||||||
|
grossSavingGBP: grossSaving,
|
||||||
|
extraDrivingCostGBP: extraFuelCost,
|
||||||
|
extraDistanceMiles: extraDistance,
|
||||||
|
netSavingGBP: netSaving,
|
||||||
|
status: status,
|
||||||
|
distanceBasis: distanceBasis,
|
||||||
|
journeyConfidence: journeyConfidence
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sortedForWorthTheDrive(_ stations: [FuelSavingsRankedStation]) -> [FuelSavingsRankedStation] {
|
||||||
|
stations.sorted { lhs, rhs in
|
||||||
|
switch (lhs.result.result, rhs.result.result) {
|
||||||
|
case let (l?, r?):
|
||||||
|
if l.netSavingGBP != r.netSavingGBP { return l.netSavingGBP > r.netSavingGBP }
|
||||||
|
if lhs.distanceMiles != rhs.distanceMiles { return lhs.distanceMiles < rhs.distanceMiles }
|
||||||
|
if lhs.pricePencePerLitre != rhs.pricePencePerLitre { return lhs.pricePencePerLitre < rhs.pricePencePerLitre }
|
||||||
|
return lhs.stationID < rhs.stationID
|
||||||
|
case (_?, nil):
|
||||||
|
return true
|
||||||
|
case (nil, _?):
|
||||||
|
return false
|
||||||
|
case (nil, nil):
|
||||||
|
if lhs.distanceMiles != rhs.distanceMiles { return lhs.distanceMiles < rhs.distanceMiles }
|
||||||
|
return lhs.stationID < rhs.stationID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func bestValueAlertCandidate(
|
||||||
|
stations: [FuelStation],
|
||||||
|
fuel: FuelType,
|
||||||
|
settings: FuelSavingsSettings,
|
||||||
|
amount: FuelSavingsFillAmount,
|
||||||
|
minimumNetSavingGBP: Double,
|
||||||
|
journeyMiles: (FuelStation) -> FuelSavingsJourney?
|
||||||
|
) -> BestValueAlertCandidate? {
|
||||||
|
guard settings.isConfigured, minimumNetSavingGBP.isFinite else { return nil }
|
||||||
|
let sellers = stations.filter { $0.prices[fuel] != nil }
|
||||||
|
guard !sellers.isEmpty else { return nil }
|
||||||
|
let baseline = FuelSavingsEstimator.baselineStation(sellers, fuel: fuel, journeyMiles: journeyMiles)
|
||||||
|
var best: BestValueAlertCandidate?
|
||||||
|
var bestDistance = Double.greatestFiniteMagnitude
|
||||||
|
var bestPrice = Double.infinity
|
||||||
|
var baselineJourneyCache: FuelSavingsJourney?
|
||||||
|
var baselineJourneyLoaded = false
|
||||||
|
for station in sellers {
|
||||||
|
let journey = journeyMiles(station)
|
||||||
|
let baselineJourney: FuelSavingsJourney? = {
|
||||||
|
guard let baseline else { return nil }
|
||||||
|
if baseline.id == station.id { return journey }
|
||||||
|
if !baselineJourneyLoaded {
|
||||||
|
baselineJourneyCache = journeyMiles(baseline)
|
||||||
|
baselineJourneyLoaded = true
|
||||||
|
}
|
||||||
|
return baselineJourneyCache
|
||||||
|
}()
|
||||||
|
guard case .available(let result) = FuelSavingsEstimator.availability(
|
||||||
|
station: station,
|
||||||
|
fuel: fuel,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journey: journey,
|
||||||
|
baselineJourney: baselineJourney
|
||||||
|
), result.status == .worthTheDrive,
|
||||||
|
result.netSavingGBP >= minimumNetSavingGBP else { continue }
|
||||||
|
let distance = journey?.miles ?? .greatestFiniteMagnitude
|
||||||
|
let price = station.prices[fuel] ?? .infinity
|
||||||
|
guard let current = best else {
|
||||||
|
best = BestValueAlertCandidate(station: station, result: result)
|
||||||
|
bestDistance = distance
|
||||||
|
bestPrice = price
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result.netSavingGBP > current.result.netSavingGBP ||
|
||||||
|
(result.netSavingGBP == current.result.netSavingGBP && distance < bestDistance) ||
|
||||||
|
(result.netSavingGBP == current.result.netSavingGBP && distance == bestDistance && price < bestPrice) ||
|
||||||
|
(result.netSavingGBP == current.result.netSavingGBP && distance == bestDistance && price == bestPrice && station.id < current.station.id) {
|
||||||
|
best = BestValueAlertCandidate(station: station, result: result)
|
||||||
|
bestDistance = distance
|
||||||
|
bestPrice = price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns stations into savings estimates.
|
||||||
|
///
|
||||||
|
/// Pure and injectable (no MapKit, CoreLocation or SwiftUI) so the ranking
|
||||||
|
/// rules are unit tested directly.
|
||||||
|
enum FuelSavingsEstimator {
|
||||||
|
/// Baseline station for Best Value: the station with the shortest journey
|
||||||
|
/// among stations that sell the selected fuel, using the SAME journey source
|
||||||
|
/// the savings calculation will use. This keeps the row labelled "Closest
|
||||||
|
/// station" and every "extra return travel" value tied to one
|
||||||
|
/// reference, instead of mixing a separate straight-line baseline with
|
||||||
|
/// road-route savings.
|
||||||
|
static func baselineStation(_ stations: [FuelStation],
|
||||||
|
fuel: FuelType,
|
||||||
|
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> FuelStation? {
|
||||||
|
var bestStation: FuelStation?
|
||||||
|
var bestMiles = Double.infinity
|
||||||
|
for station in stations where station.prices[fuel] != nil {
|
||||||
|
guard let journey = journeyMiles(station) else { continue }
|
||||||
|
let miles = journey.miles
|
||||||
|
guard miles.isFinite, miles >= 0 else { continue }
|
||||||
|
if miles < bestMiles {
|
||||||
|
bestMiles = miles
|
||||||
|
bestStation = station
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestStation
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Savings for a single station against `baseline`, or nil when there is
|
||||||
|
/// nothing to show at all (feature off).
|
||||||
|
static func availability(station: FuelStation,
|
||||||
|
fuel: FuelType,
|
||||||
|
baseline: FuelStation?,
|
||||||
|
settings: FuelSavingsSettings,
|
||||||
|
amount: FuelSavingsFillAmount,
|
||||||
|
journey: FuelSavingsJourney?,
|
||||||
|
baselineJourney: FuelSavingsJourney?) -> FuelSavingsAvailability? {
|
||||||
|
guard settings.isConfigured else { return nil }
|
||||||
|
guard let baseline, let baselineJourney else { return .unavailable("No comparison station") }
|
||||||
|
guard let baselinePrice = baseline.prices[fuel] else { return .unavailable("No comparison station") }
|
||||||
|
guard let price = station.prices[fuel] else { return .unavailable("No price") }
|
||||||
|
guard let journey else { return .unavailable("Distance unavailable") }
|
||||||
|
// Both journeys must be measured, complete road routes for the comparison
|
||||||
|
// to be confirmed. Legacy one-way caches keep the road-route basis for
|
||||||
|
// distance arithmetic but are still provisional because the return leg is
|
||||||
|
// inferred rather than measured.
|
||||||
|
let basis: FuelSavingsDistanceBasis =
|
||||||
|
(journey.basis == .roadRoute && baselineJourney.basis == .roadRoute) ? .roadRoute : .straightLine
|
||||||
|
let confidence: JourneyConfidence =
|
||||||
|
(journey.confidence == .confirmed && baselineJourney.confidence == .confirmed) ? .confirmed : .estimated
|
||||||
|
do {
|
||||||
|
let result = try FuelSavingsCalculator.calculate(
|
||||||
|
mpg: settings.mpg,
|
||||||
|
amount: amount,
|
||||||
|
baselinePricePencePerLitre: baselinePrice,
|
||||||
|
candidatePricePencePerLitre: price,
|
||||||
|
baselineJourneyMiles: baselineJourney.miles,
|
||||||
|
candidateJourneyMiles: journey.miles,
|
||||||
|
distanceBasis: basis,
|
||||||
|
journeyConfidence: confidence
|
||||||
|
)
|
||||||
|
// The baseline compares against itself: label it as the closest
|
||||||
|
// station rather than a meaningless "Break even".
|
||||||
|
return .available(station.id == baseline.id ? result.asBaseline() : result)
|
||||||
|
} catch {
|
||||||
|
return .unavailable("Distance unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ranks a whole pool for the Best Value sort.
|
||||||
|
///
|
||||||
|
/// EVERY priced station in the pool gets an estimate, not just the handful
|
||||||
|
/// whose road routes happen to have been measured: journeys that have no
|
||||||
|
/// measured route fall back to a straight-line out-and-back estimate (see
|
||||||
|
/// `FuelSavingsDistanceBasis`). Capping this to a shortlist dropped cheaper
|
||||||
|
/// stations that were a few miles further away — the exact stations the
|
||||||
|
/// sort exists to surface — and left later pages with no savings at all.
|
||||||
|
static func ranked(_ stations: [FuelStation],
|
||||||
|
fuel: FuelType,
|
||||||
|
baseline: FuelStation?,
|
||||||
|
settings: FuelSavingsSettings,
|
||||||
|
amount: FuelSavingsFillAmount,
|
||||||
|
journeyMiles: (FuelStation) -> FuelSavingsJourney?) -> [FuelStation] {
|
||||||
|
var journeys: [String: FuelSavingsJourney] = [:]
|
||||||
|
journeys.reserveCapacity(stations.count)
|
||||||
|
for station in stations {
|
||||||
|
if let journey = journeyMiles(station) {
|
||||||
|
journeys[station.id] = journey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let baselineJourney = baseline.flatMap { journeys[$0.id] }
|
||||||
|
let ranked = stations.map { station -> FuelSavingsRankedStation in
|
||||||
|
let journey = journeys[station.id]
|
||||||
|
let availability = availability(station: station,
|
||||||
|
fuel: fuel,
|
||||||
|
baseline: baseline,
|
||||||
|
settings: settings,
|
||||||
|
amount: amount,
|
||||||
|
journey: journey,
|
||||||
|
baselineJourney: baselineJourney) ?? .unavailable("Unavailable")
|
||||||
|
return FuelSavingsRankedStation(
|
||||||
|
stationID: station.id,
|
||||||
|
result: availability,
|
||||||
|
distanceMiles: journey?.miles ?? .greatestFiniteMagnitude,
|
||||||
|
pricePencePerLitre: station.prices[fuel] ?? .infinity
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var order: [String: Int] = [:]
|
||||||
|
for (offset, rankedStation) in FuelSavingsCalculator.sortedForWorthTheDrive(ranked).enumerated() {
|
||||||
|
if order[rankedStation.stationID] == nil {
|
||||||
|
order[rankedStation.stationID] = offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stations.sorted { lhs, rhs in
|
||||||
|
let left = order[lhs.id] ?? Int.max
|
||||||
|
let right = order[rhs.id] ?? Int.max
|
||||||
|
if left != right { return left < right }
|
||||||
|
return lhs.id < rhs.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+615
-34
@@ -45,6 +45,7 @@ struct FavouriteAlertSnapshot: Codable, Equatable {
|
|||||||
enum SortMode: String, Codable, CaseIterable, Identifiable {
|
enum SortMode: String, Codable, CaseIterable, Identifiable {
|
||||||
case cheapest
|
case cheapest
|
||||||
case closest
|
case closest
|
||||||
|
case worthTheDrive
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
@@ -52,6 +53,25 @@ enum SortMode: String, Codable, CaseIterable, Identifiable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .cheapest: return "Cheapest"
|
case .cheapest: return "Cheapest"
|
||||||
case .closest: return "Closest"
|
case .closest: return "Closest"
|
||||||
|
// "Best Value" is the user-facing name of this sort — the segmented
|
||||||
|
// control, the Stations caption, the Settings toggle and narrative, and
|
||||||
|
// the sheet's section header all read it. The raw value stays
|
||||||
|
// `worthTheDrive` so stored preferences and widget configs keep decoding.
|
||||||
|
case .worthTheDrive: return "Best Value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AlertMode: String, Codable, CaseIterable, Identifiable {
|
||||||
|
case cheapest
|
||||||
|
case bestValue
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .cheapest: return "Cheapest station"
|
||||||
|
case .bestValue: return "Best Value"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,6 +206,20 @@ enum DistanceUnit: String, Codable, CaseIterable, Identifiable {
|
|||||||
func format(_ km: Double) -> String {
|
func format(_ km: Double) -> String {
|
||||||
String(format: "%.1f %@", fromKM(km), shortName)
|
String(format: "%.1f %@", fromKM(km), shortName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spoken form of a KM distance ("6.4 miles", "10.3 kilometres"), for
|
||||||
|
/// VoiceOver. `format(_:)` returns the compact "6.4 mi"/"1.2 km" suffix that a
|
||||||
|
/// synthesizer may read as letters or skip, and "Approx." needs a word too, so
|
||||||
|
/// every spoken distance goes through here.
|
||||||
|
func spoken(_ km: Double) -> String {
|
||||||
|
switch self {
|
||||||
|
case .miles:
|
||||||
|
let miles = fromKM(km)
|
||||||
|
return String(format: "%.1f %@", miles, label(for: miles))
|
||||||
|
case .kilometers:
|
||||||
|
return String(format: "%.1f kilometres", km)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Display style for fuel prices. Internally prices are always stored and
|
/// Display style for fuel prices. Internally prices are always stored and
|
||||||
@@ -208,6 +242,25 @@ enum PriceDisplayStyle: String, Codable, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Station tap action
|
||||||
|
|
||||||
|
/// What happens when a station row is tapped in the Stations/Favourites tabs:
|
||||||
|
/// open Apple Maps directions immediately (historic behaviour, default), or
|
||||||
|
/// show a detail sheet (name, address, distance, Directions button).
|
||||||
|
enum StationTapAction: String, Codable, CaseIterable, Identifiable {
|
||||||
|
case openMap // tap → Apple Maps directions straight away
|
||||||
|
case showDetails // tap → detail sheet, Directions button inside
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .openMap: return "Open map"
|
||||||
|
case .showDetails: return "More info"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Station model
|
// MARK: - Station model
|
||||||
|
|
||||||
struct FuelStation: Identifiable, Codable, Equatable {
|
struct FuelStation: Identifiable, Codable, Equatable {
|
||||||
@@ -252,6 +305,30 @@ struct FuelStation: Identifiable, Codable, Equatable {
|
|||||||
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")
|
URL(string: "maps://?daddr=\(lat),\(lng)&t=d")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// In-app deep link to this station's More info sheet. Carries the
|
||||||
|
/// stationID plus coordinate/name fallback so the app can still show the
|
||||||
|
/// map sheet when the station has left the cache.
|
||||||
|
var detailDeepLinkURL: URL? {
|
||||||
|
var components = URLComponents()
|
||||||
|
components.scheme = "fuelboard"
|
||||||
|
components.host = "station"
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "stationID", value: id),
|
||||||
|
URLQueryItem(name: "lat", value: String(lat)),
|
||||||
|
URLQueryItem(name: "lng", value: String(lng)),
|
||||||
|
URLQueryItem(name: "name", value: name),
|
||||||
|
]
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget/Live Activity tap destination honouring Settings → Tap station:
|
||||||
|
/// Open map → Apple Maps directions; More info → in-app detail deep link.
|
||||||
|
var tapDestinationURL: URL? {
|
||||||
|
FuelStore.loadStationTapAction() == .showDetails
|
||||||
|
? detailDeepLinkURL
|
||||||
|
: widgetDirectionsURL
|
||||||
|
}
|
||||||
|
|
||||||
/// Name of the bundled brand logo asset, or nil if unknown.
|
/// Name of the bundled brand logo asset, or nil if unknown.
|
||||||
/// Normalizes messy raw brand strings ("SHELL LEEDS ROAD" → "shell").
|
/// Normalizes messy raw brand strings ("SHELL LEEDS ROAD" → "shell").
|
||||||
var brandImageName: String? {
|
var brandImageName: String? {
|
||||||
@@ -358,13 +435,14 @@ struct FuelStore {
|
|||||||
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
|
static let alertsEnabledKey = "fuelboard.alertsEnabled" // Bool
|
||||||
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
|
static let alertsRadiusKey = "fuelboard.alertsRadius" // Double km
|
||||||
static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value
|
static let alertsFuelKey = "fuelboard.alertsFuel" // FuelType raw value
|
||||||
|
static let alertsModeKey = "fuelboard.alertsMode" // AlertMode raw value
|
||||||
|
static let alertsBestValueThresholdKey = "fuelboard.alertsBestValueThresholdGBP" // Double £
|
||||||
static let alertsFollowSearchKey = "fuelboard.alertsFollowSearch" // Bool — alerts mirror the Stations-tab distance
|
static let alertsFollowSearchKey = "fuelboard.alertsFollowSearch" // Bool — alerts mirror the Stations-tab distance
|
||||||
static let favouriteDropAlertsEnabledKey = "fuelboard.favouriteDropAlertsEnabled" // Bool
|
static let favouriteDropAlertsEnabledKey = "fuelboard.favouriteDropAlertsEnabled" // Bool
|
||||||
static let favouriteDropAlertsFuelKey = "fuelboard.favouriteDropAlertsFuel" // FuelType raw value
|
static let favouriteDropAlertsFuelKey = "fuelboard.favouriteDropAlertsFuel" // FuelType raw value
|
||||||
static let favouriteAlertSnapshotsKey = "fuelboard.favouriteAlertSnapshots" // [fuel: FavouriteAlertSnapshot] JSON
|
static let favouriteAlertSnapshotsKey = "fuelboard.favouriteAlertSnapshots" // [fuel: FavouriteAlertSnapshot] JSON
|
||||||
static let liveActivityFollowSearchKey = "fuelboard.liveActivityFollowSearch" // Bool — Live Activity mirrors the Stations-tab distance
|
static let liveActivityFollowSearchKey = "fuelboard.liveActivityFollowSearch" // Bool — Live Activity mirrors the Stations-tab distance
|
||||||
static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag
|
static let debugModeKey = "fuelboard.debugMode" // Bool — hidden dev flag
|
||||||
static let relayFallbackKey = "fuelboard.relayFallback" // Bool — dev-only relay fallback
|
|
||||||
static let liveActivityKey = "fuelboard.liveActivity" // Bool — Live Activity toggle
|
static let liveActivityKey = "fuelboard.liveActivity" // Bool — Live Activity toggle
|
||||||
static let liveActivityFuelKey = "fuelboard.liveActivityFuel" // FuelType raw value
|
static let liveActivityFuelKey = "fuelboard.liveActivityFuel" // FuelType raw value
|
||||||
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
|
static let liveActivityRadiusKey = "fuelboard.liveActivityRadiusMiles" // Int miles (5/10/15)
|
||||||
@@ -376,13 +454,69 @@ struct FuelStore {
|
|||||||
static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled" // TimeInterval (seconds since 1970)
|
static let watchRefreshHandledKey = "fuelboard.watchRefreshHandled" // TimeInterval (seconds since 1970)
|
||||||
static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv"
|
static let relaySourceKey = "fuelboard.relaySource" // String — "api" | "csv"
|
||||||
static let stationCountKey = "fuelboard.stationCount" // String — station count
|
static let stationCountKey = "fuelboard.stationCount" // String — station count
|
||||||
static let dataUpdatedKey = "fuelboard.dataUpdated" // String — govUK dataset update time
|
static let dataUpdatedKey = "fuelboard.dataUpdated" // govUK dataset update time
|
||||||
|
static let fuelSavingsEnabledKey = "fuelboard.fuelSavings.enabled"
|
||||||
|
static let fuelSavingsMPGKey = "fuelboard.fuelSavings.mpg"
|
||||||
|
static let fuelSavingsAmountKey = "fuelboard.fuelSavings.amount"
|
||||||
|
static let fuelSavingsUnitKey = "fuelboard.fuelSavings.unit"
|
||||||
|
|
||||||
|
// Large shared payloads belong in the App Group container, not
|
||||||
|
// UserDefaults/CFPreferences (which has a roughly 4 MB limit).
|
||||||
|
static let stationsFileName = "fuelboard.stations.json"
|
||||||
|
|
||||||
|
/// Where a shared payload is written: the App Group container when the
|
||||||
|
/// provisioning profile grants it, otherwise the app's OWN container.
|
||||||
|
///
|
||||||
|
/// This fallback is essential in the field. Free/personal-team and SideStore
|
||||||
|
/// profiles do NOT include the `com.apple.security.application-groups`
|
||||||
|
/// entitlement, so `containerURL(forSecurityApplicationGroupIdentifier:)`
|
||||||
|
/// returns nil at runtime. Without this fallback NOTHING persisted on those
|
||||||
|
/// installs: `loadStations()` was permanently empty (so the freshness gate
|
||||||
|
/// could skip the fetch for ever and leave a blank list) and the ~2.8 MB
|
||||||
|
/// dump was re-downloaded on every refresh. The app's own container is not
|
||||||
|
/// shared with the widget/watch extension, but the app itself must keep a
|
||||||
|
/// working cache.
|
||||||
|
static func appGroupFileURL(_ name: String) -> URL? {
|
||||||
|
let fm = FileManager.default
|
||||||
|
if let group = fm.containerURL(forSecurityApplicationGroupIdentifier: appGroupSuite),
|
||||||
|
fm.fileExists(atPath: group.path) {
|
||||||
|
// Only use the App Group container when the system actually
|
||||||
|
// provisioned it at install time (i.e. the profile carries the
|
||||||
|
// `com.apple.security.application-groups` entitlement). Never try to
|
||||||
|
// create it: when the entitlement is missing the path either does
|
||||||
|
// not exist or is not writable, and probing it that way failed every
|
||||||
|
// write instead of falling back.
|
||||||
|
return group.appendingPathComponent(name, isDirectory: false)
|
||||||
|
}
|
||||||
|
guard let dir = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !fm.fileExists(atPath: dir.path),
|
||||||
|
(try? fm.createDirectory(at: dir, withIntermediateDirectories: true)) == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dir.appendingPathComponent(name, isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadAppGroupFile(_ name: String) -> Data? {
|
||||||
|
guard let url = appGroupFileURL(name) else { return nil }
|
||||||
|
return try? Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func saveAppGroupFile(_ data: Data, name: String) -> Bool {
|
||||||
|
guard let url = appGroupFileURL(name) else { return false }
|
||||||
|
do {
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Stations
|
// MARK: Stations
|
||||||
// The full-UK dataset (~2.9 MB) lives in app-group UserDefaults only —
|
// The full-UK dataset is stored as an App Group file. UserDefaults is
|
||||||
// keychain is for small values and cannot hold it. Read order is
|
// reserved for small preferences and has a roughly 4 MB total limit.
|
||||||
// defaults-first for stations (keychain may hold a legacy small set from
|
|
||||||
// older builds; the full country dump always wins).
|
|
||||||
|
|
||||||
/// Stations from `stations` tied with `price` for `fuel` (within 0.01p),
|
/// Stations from `stations` tied with `price` for `fuel` (within 0.01p),
|
||||||
/// sorted nearest-first from `lat`/`lng`. Used to offer multiple
|
/// sorted nearest-first from `lat`/`lng`. Used to offer multiple
|
||||||
@@ -401,10 +535,23 @@ struct FuelStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func loadStations() -> [FuelStation] {
|
static func loadStations() -> [FuelStation] {
|
||||||
|
#if DEBUG
|
||||||
|
let probe = appGroupFileURL(stationsFileName)
|
||||||
|
print("FB-STORAGE load \(stationsFileName) url=\(probe?.path ?? "nil") present=\(probe.map { FileManager.default.fileExists(atPath: $0.path) } ?? false)")
|
||||||
|
#endif
|
||||||
|
if let data = loadAppGroupFile(stationsFileName),
|
||||||
|
let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
|
||||||
|
!stations.isEmpty {
|
||||||
|
return stations.map(sanitized)
|
||||||
|
}
|
||||||
|
// Migrate the legacy App Group UserDefaults value once.
|
||||||
if let defaults = UserDefaults(suiteName: appGroupSuite),
|
if let defaults = UserDefaults(suiteName: appGroupSuite),
|
||||||
let data = defaults.data(forKey: stationsKey),
|
let data = defaults.data(forKey: stationsKey),
|
||||||
let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
|
let stations = try? JSONDecoder().decode([FuelStation].self, from: data),
|
||||||
!stations.isEmpty {
|
!stations.isEmpty {
|
||||||
|
if saveAppGroupFile(data, name: stationsFileName) {
|
||||||
|
defaults.removeObject(forKey: stationsKey)
|
||||||
|
}
|
||||||
return stations.map(sanitized)
|
return stations.map(sanitized)
|
||||||
}
|
}
|
||||||
if let data = keychainData(service: stationsKey),
|
if let data = keychainData(service: stationsKey),
|
||||||
@@ -425,8 +572,10 @@ struct FuelStore {
|
|||||||
|
|
||||||
static func saveStations(_ stations: [FuelStation]) {
|
static func saveStations(_ stations: [FuelStation]) {
|
||||||
if let data = try? JSONEncoder().encode(stations) {
|
if let data = try? JSONEncoder().encode(stations) {
|
||||||
UserDefaults(suiteName: appGroupSuite)?.set(data, forKey: stationsKey)
|
let ok = saveAppGroupFile(data, name: stationsFileName)
|
||||||
// Intentionally NOT written to keychain — 2.9 MB exceeds its limits.
|
#if DEBUG
|
||||||
|
print("FB-STORAGE save \(stationsFileName) bytes=\(data.count) ok=\(ok) url=\(appGroupFileURL(stationsFileName)?.path ?? "nil")")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,6 +601,29 @@ struct FuelStore {
|
|||||||
saveString("\(lat),\(lng),\(date.timeIntervalSince1970)", service: locationKey)
|
saveString("\(lat),\(lng),\(date.timeIntervalSince1970)", service: locationKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A fix the WIDGET EXTENSION obtained on its own, kept apart from
|
||||||
|
/// `locationKey`.
|
||||||
|
///
|
||||||
|
/// The extension's fix comes from its own `requestLocation()` and is often
|
||||||
|
/// coarse; writing it over `locationKey` replaced the app's GPS fix with a
|
||||||
|
/// Wi-Fi-grade one that could sit hundreds of metres away. That stored fix is
|
||||||
|
/// what the app seeds itself with on launch and what
|
||||||
|
/// `distanceDisplayOrigin` trusts when a coarse fix disagrees with the route
|
||||||
|
/// cache — so a widget fix must never be mistaken for the app's position.
|
||||||
|
static let widgetLocationKey = "fuelboard.widgetLocation"
|
||||||
|
|
||||||
|
static func saveWidgetLocation(lat: Double, lng: Double, date: Date = Date()) {
|
||||||
|
saveString("\(lat),\(lng),\(date.timeIntervalSince1970)", service: widgetLocationKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadWidgetLocation() -> (coordinate: Coordinate, date: Date)? {
|
||||||
|
let raw = loadString(service: widgetLocationKey)
|
||||||
|
guard let parts = raw?.split(separator: ",").compactMap({ Double($0) }),
|
||||||
|
parts.count == 3 else { return nil }
|
||||||
|
return (Coordinate(lat: parts[0], lng: parts[1]),
|
||||||
|
Date(timeIntervalSince1970: parts[2]))
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Selected fuel
|
// MARK: Selected fuel
|
||||||
|
|
||||||
static func loadSelectedFuel() -> FuelType {
|
static func loadSelectedFuel() -> FuelType {
|
||||||
@@ -480,6 +652,34 @@ struct FuelStore {
|
|||||||
saveString(fuel.rawValue, service: alertsFuelKey)
|
saveString(fuel.rawValue, service: alertsFuelKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func loadAlertsMode() -> AlertMode {
|
||||||
|
if let raw = loadString(service: alertsModeKey), let mode = AlertMode(rawValue: raw) {
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
return .cheapest
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveAlertsMode(_ mode: AlertMode) {
|
||||||
|
saveString(mode.rawValue, service: alertsModeKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static let bestValueAlertThresholdOptionsGBP: [Double] = [0.5, 1.0, 2.0, 5.0]
|
||||||
|
|
||||||
|
static func loadAlertsBestValueThresholdGBP() -> Double {
|
||||||
|
if let raw = loadString(service: alertsBestValueThresholdKey),
|
||||||
|
let value = Double(raw),
|
||||||
|
bestValueAlertThresholdOptionsGBP.contains(value) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveAlertsBestValueThresholdGBP(_ threshold: Double) {
|
||||||
|
let snapped = bestValueAlertThresholdOptionsGBP
|
||||||
|
.min(by: { abs($0 - threshold) < abs($1 - threshold) }) ?? 1.0
|
||||||
|
saveString(String(snapped), service: alertsBestValueThresholdKey)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Sort mode
|
// MARK: Sort mode
|
||||||
|
|
||||||
static func loadSortMode() -> SortMode {
|
static func loadSortMode() -> SortMode {
|
||||||
@@ -542,6 +742,57 @@ struct FuelStore {
|
|||||||
saveString(style.rawValue, service: priceDisplayStyleKey)
|
saveString(style.rawValue, service: priceDisplayStyleKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Station tap action — open map vs detail sheet. Stored raw value;
|
||||||
|
// default open map preserves the historic tap behaviour for existing installs.
|
||||||
|
|
||||||
|
static let stationTapActionKey = "fuelboard.stationTapAction"
|
||||||
|
|
||||||
|
static func loadStationTapAction() -> StationTapAction {
|
||||||
|
if let raw = loadString(service: stationTapActionKey), let action = StationTapAction(rawValue: raw) {
|
||||||
|
return action
|
||||||
|
}
|
||||||
|
return .openMap
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveStationTapAction(_ action: StationTapAction) {
|
||||||
|
saveString(action.rawValue, service: stationTapActionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Fuel savings — Worth the Drive?
|
||||||
|
|
||||||
|
static func loadFuelSavingsSettings() -> FuelSavingsSettings {
|
||||||
|
let enabled = loadString(service: fuelSavingsEnabledKey) == "1"
|
||||||
|
let mpg = loadString(service: fuelSavingsMPGKey).flatMap(Double.init) ?? FuelSavingsSettings.defaultMPG
|
||||||
|
let amount = loadString(service: fuelSavingsAmountKey).flatMap(Double.init) ?? FuelSavingsSettings.defaultPreferredAmount.value
|
||||||
|
let unit = loadString(service: fuelSavingsUnitKey).flatMap(FuelSavingsFillUnit.init(rawValue:)) ?? FuelSavingsSettings.defaultPreferredAmount.unit
|
||||||
|
#if DEBUG
|
||||||
|
// The two stores are mirrors and can DISAGREE (the keychain survives
|
||||||
|
// app deletion, the app-group defaults do not). Print both raw copies
|
||||||
|
// plus which one won, so a setting that flips between launches is
|
||||||
|
// diagnosable instead of guesswork.
|
||||||
|
func raw(_ service: String) -> (keychain: String, appGroup: String) {
|
||||||
|
let keychain = keychainData(service: service).flatMap { String(data: $0, encoding: .utf8) } ?? "-"
|
||||||
|
let appGroup = UserDefaults(suiteName: appGroupSuite)?.string(forKey: service) ?? "-"
|
||||||
|
return (keychain, appGroup)
|
||||||
|
}
|
||||||
|
let enabledRaw = raw(fuelSavingsEnabledKey)
|
||||||
|
let mpgRaw = raw(fuelSavingsMPGKey)
|
||||||
|
print("FB-SAVINGS read enabled=\(enabled ? 1 : 0) [kc=\(enabledRaw.keychain) ag=\(enabledRaw.appGroup)] mpg=\(mpg) [kc=\(mpgRaw.keychain) ag=\(mpgRaw.appGroup)]")
|
||||||
|
#endif
|
||||||
|
return FuelSavingsSettings(
|
||||||
|
enabled: enabled,
|
||||||
|
mpg: mpg,
|
||||||
|
preferredAmount: FuelSavingsFillAmount(value: amount, unit: unit)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveFuelSavingsSettings(_ settings: FuelSavingsSettings) {
|
||||||
|
saveString(settings.enabled ? "1" : "0", service: fuelSavingsEnabledKey)
|
||||||
|
saveString(String(settings.mpg), service: fuelSavingsMPGKey)
|
||||||
|
saveString(String(settings.preferredAmount.value), service: fuelSavingsAmountKey)
|
||||||
|
saveString(settings.preferredAmount.unit.rawValue, service: fuelSavingsUnitKey)
|
||||||
|
}
|
||||||
|
|
||||||
/// Superscript digit glyphs for the pounds & pence format's small raised
|
/// Superscript digit glyphs for the pounds & pence format's small raised
|
||||||
/// third digit (the forecourt style: "£1.29⁹").
|
/// third digit (the forecourt style: "£1.29⁹").
|
||||||
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
private static let superscriptDigits: [Character] = ["⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹"]
|
||||||
@@ -620,22 +871,6 @@ struct FuelStore {
|
|||||||
saveString(enabled ? "1" : "0", service: debugModeKey)
|
saveString(enabled ? "1" : "0", service: debugModeKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Relay fallback (dev-only)
|
|
||||||
|
|
||||||
/// Dev-only switch: re-inserts the LAN relay into the live chain as a
|
|
||||||
/// fallback for home testing. OFF by default — consumers must never make
|
|
||||||
/// a local-network attempt (the relay is unreachable off the developer's
|
|
||||||
/// LAN, and the attempt itself would fire the iOS Local Network prompt).
|
|
||||||
/// Toggled from Settings → Debug; keychain-first so it survives the
|
|
||||||
/// delete → reinstall test loop like every other small setting.
|
|
||||||
static func loadRelayFallbackEnabled() -> Bool {
|
|
||||||
loadString(service: relayFallbackKey) == "1"
|
|
||||||
}
|
|
||||||
|
|
||||||
static func saveRelayFallbackEnabled(_ enabled: Bool) {
|
|
||||||
saveString(enabled ? "1" : "0", service: relayFallbackKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Favourites
|
// MARK: Favourites
|
||||||
|
|
||||||
/// A favourite pins a station FOR ONE fuel type. Starring a row while
|
/// A favourite pins a station FOR ONE fuel type. Starring a row while
|
||||||
@@ -1052,6 +1287,60 @@ struct FuelStore {
|
|||||||
return !loadHasCompletedOnboarding() || fresh
|
return !loadHasCompletedOnboarding() || fresh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Best Value announcement (existing users who updated into it)
|
||||||
|
|
||||||
|
/// Users who finished onboarding before Best Value existed are
|
||||||
|
/// `completed == true, fresh == false` for good, so the first-run gate above
|
||||||
|
/// never fires again for them and the feature would stay invisible unless
|
||||||
|
/// they went looking in Settings. This flag drives a single-page
|
||||||
|
/// announcement instead of replaying the whole flow (which would re-run
|
||||||
|
/// permission pages they have already answered).
|
||||||
|
///
|
||||||
|
/// Stores the announcement version SEEN, compared against
|
||||||
|
/// `currentBestValueIntroVersion` below — deliberately not the app version,
|
||||||
|
/// so a bugfix release never re-announces anything.
|
||||||
|
static let bestValueIntroVersionKey = "fuelboard.bestValueIntroVersion"
|
||||||
|
|
||||||
|
/// Bump this when the Best Value announcement should be shown again to
|
||||||
|
/// everyone — e.g. the feature changes in a way worth re-explaining. Anyone
|
||||||
|
/// whose stored value differs (including someone who dismissed an earlier
|
||||||
|
/// announcement) sees it once more on the next launch.
|
||||||
|
static let currentBestValueIntroVersion = 1
|
||||||
|
|
||||||
|
static func hasSeenBestValueIntro() -> Bool {
|
||||||
|
(loadString(service: bestValueIntroVersionKey) ?? "")
|
||||||
|
== String(currentBestValueIntroVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func markBestValueIntroSeen() {
|
||||||
|
saveString(String(currentBestValueIntroVersion), service: bestValueIntroVersionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the standalone Best Value announcement should be offered.
|
||||||
|
///
|
||||||
|
/// The rule lives here rather than inside the view so it can be unit-tested:
|
||||||
|
/// a view-embedded gate can only be checked by driving a simulator through
|
||||||
|
/// every combination, which is exactly how a rule like this rots. The facts
|
||||||
|
/// it depends on are injectable for the same reason.
|
||||||
|
///
|
||||||
|
/// It declines in four cases, each for a different reason:
|
||||||
|
/// - `isSuppressed` — the first-run onboarding cover is up and must not be
|
||||||
|
/// fought for the screen;
|
||||||
|
/// - `onboardingDue` — a new install is about to run the full flow, which
|
||||||
|
/// already contains this page (`OnboardingView.finish` also marks it seen);
|
||||||
|
/// - `hasSeen` — this announcement version has already been shown;
|
||||||
|
/// - the user has Best Value configured — never announce to someone who set
|
||||||
|
/// it up themselves.
|
||||||
|
static func shouldAnnounceBestValue(settings: FuelSavingsSettings,
|
||||||
|
hasSeen: Bool = hasSeenBestValueIntro(),
|
||||||
|
onboardingDue: Bool = shouldShowOnboarding(),
|
||||||
|
isSuppressed: Bool = false) -> Bool {
|
||||||
|
guard !isSuppressed else { return false }
|
||||||
|
guard !onboardingDue else { return false }
|
||||||
|
guard !hasSeen else { return false }
|
||||||
|
return !settings.isConfigured
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Road distances (Apple-Maps-matched, computed by the app)
|
// MARK: Road distances (Apple-Maps-matched, computed by the app)
|
||||||
|
|
||||||
/// Cached road/routed distances (metres) keyed by station ID, computed by
|
/// Cached road/routed distances (metres) keyed by station ID, computed by
|
||||||
@@ -1074,20 +1363,173 @@ struct FuelStore {
|
|||||||
/// catches any real pin change.
|
/// catches any real pin change.
|
||||||
static let roadDistancePinToleranceDegrees: Double = 1e-4
|
static let roadDistancePinToleranceDegrees: Double = 1e-4
|
||||||
|
|
||||||
static func saveRoadDistances(sourceLat: Double, sourceLng: Double, entries: [String: CachedRoadDistance]) {
|
// MARK: Accurate distances (browse-layer toggle)
|
||||||
|
|
||||||
|
/// "Accurate distances". OFF (default) = the browse layer never uses a
|
||||||
|
/// routed road distance: every surface shows the straight-line value marked
|
||||||
|
/// "Approx.", so two devices agree exactly and the app makes no route
|
||||||
|
/// requests at all. ON = routed distances are shown, but only once the whole
|
||||||
|
/// search area has been routed from an anchor we are still standing near, so
|
||||||
|
/// a list is never a mix of road and straight-line values.
|
||||||
|
static let accurateDistancesKey = "fuelboard.accurateDistances"
|
||||||
|
|
||||||
|
/// Memoised, because this is read from the DISPLAY hot path.
|
||||||
|
///
|
||||||
|
/// `UserDefaults(suiteName:)` builds a fresh suite object and forces a
|
||||||
|
/// synchronous cfprefsd read, and `stationDistanceDisplay` consults this
|
||||||
|
/// property on every call — which the Closest sort used to make twice per
|
||||||
|
/// comparison (~200k reads for the whole-country pool), so switching to
|
||||||
|
/// Closest or launching with it selected stalled the main thread for seconds
|
||||||
|
/// on XPC alone. Same failure class as the keychain read that once tripped
|
||||||
|
/// the launch watchdog. Only this app writes the key, and it writes it
|
||||||
|
/// through `setAccurateDistancesEnabled`, which keeps the memo in step;
|
||||||
|
/// each PROCESS reads the stored value once (a widget extension has its own).
|
||||||
|
private static var accurateDistancesMemo: Bool?
|
||||||
|
|
||||||
|
static var accurateDistancesEnabled: Bool {
|
||||||
|
if let memo = accurateDistancesMemo { return memo }
|
||||||
|
let stored = UserDefaults(suiteName: appGroupSuite)?.bool(forKey: accurateDistancesKey) ?? false
|
||||||
|
accurateDistancesMemo = stored
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
|
||||||
|
static func setAccurateDistancesEnabled(_ enabled: Bool) {
|
||||||
|
accurateDistancesMemo = enabled
|
||||||
|
UserDefaults(suiteName: appGroupSuite)?.set(enabled, forKey: accurateDistancesKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether routed distances may be shown right now: the toggle is on AND the
|
||||||
|
/// cached routes cover the whole search area from a location we are still
|
||||||
|
/// near. Anything else serves straight-line + "Approx." for EVERY row, which
|
||||||
|
/// is what stops a list from mixing bases (a row reading "14.6 mi" inside a
|
||||||
|
/// "10 miles" list) and stops membership changing row by row as a fill lands.
|
||||||
|
static func accurateDistancesReady(userLat: Double, userLng: Double) -> Bool {
|
||||||
|
guard accurateDistancesEnabled else { return false }
|
||||||
|
guard let cache = loadRoadDistances(), cache.complete == true else { return false }
|
||||||
|
return haversineMetres(cache.sourceLat, cache.sourceLng, userLat, userLng)
|
||||||
|
<= roadDistanceOriginToleranceMeters
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How imprecise a fix must be before its distance from the route cache's
|
||||||
|
/// anchor stops being read as "the user moved". The widget extension runs its
|
||||||
|
/// own `requestLocation()` on a `kCLLocationAccuracyHundredMeters` manager and
|
||||||
|
/// is routinely handed a coarse Wi-Fi/cell fix (or a cached one from earlier
|
||||||
|
/// in the day) — hundreds of metres from the app's GPS anchor.
|
||||||
|
static let roadDistanceCoarseFixAccuracyMeters: Double = 150
|
||||||
|
|
||||||
|
/// How far a coarse fix may sit from the anchor before it is taken as genuine
|
||||||
|
/// travel rather than fix noise.
|
||||||
|
static let roadDistanceCoarseFixRadiusMeters: Double = 3_000
|
||||||
|
|
||||||
|
/// Where a surface should read displayed distances FROM, and whether routed
|
||||||
|
/// distances are usable there.
|
||||||
|
///
|
||||||
|
/// Routed distances are anchored to the fix that produced them (the app's GPS
|
||||||
|
/// fix). A surface in another process — the widget extension — gets its OWN
|
||||||
|
/// fix, which can sit hundreds of metres from that anchor. Reading that as
|
||||||
|
/// "the user moved away from the cache" downgraded EVERY row in the widget to
|
||||||
|
/// straight-line + "Approx." while the app, whose fix IS the anchor, showed
|
||||||
|
/// routed values: one station read differently on two faces, and refreshing
|
||||||
|
/// never fixed it because each new widget fix landed outside the ring again.
|
||||||
|
/// Resolve the origin once, here, so every surface labels from the same place.
|
||||||
|
struct DistanceDisplayOrigin: Equatable {
|
||||||
|
let lat: Double
|
||||||
|
let lng: Double
|
||||||
|
/// True when the caller may serve routed distances from this origin.
|
||||||
|
let routed: Bool
|
||||||
|
/// Which fix decided it — diagnostics only.
|
||||||
|
let source: String
|
||||||
|
}
|
||||||
|
|
||||||
|
static func distanceDisplayOrigin(candidateLat: Double, candidateLng: Double,
|
||||||
|
candidateAccuracyMeters: Double? = nil) -> DistanceDisplayOrigin {
|
||||||
|
let straightLine = DistanceDisplayOrigin(lat: candidateLat, lng: candidateLng,
|
||||||
|
routed: false, source: "straightLine")
|
||||||
|
guard accurateDistancesEnabled,
|
||||||
|
let cache = loadRoadDistances(), cache.complete == true else { return straightLine }
|
||||||
|
|
||||||
|
let candidateOffset = haversineMetres(cache.sourceLat, cache.sourceLng, candidateLat, candidateLng)
|
||||||
|
if candidateOffset <= roadDistanceOriginToleranceMeters {
|
||||||
|
return DistanceDisplayOrigin(lat: candidateLat, lng: candidateLng,
|
||||||
|
routed: true, source: "candidate")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The candidate fix is outside the ring. A precise fix there means the
|
||||||
|
// user really has moved, and the honest answer is the straight line from
|
||||||
|
// where they are. A COARSE fix (or one that reported no accuracy) is fix
|
||||||
|
// noise, as is any disagreement under a kilometre — neither is good
|
||||||
|
// evidence that the routes are stale, so fall back to the app's own fix,
|
||||||
|
// which is the one the cached routes were built around.
|
||||||
|
let accuracy = candidateAccuracyMeters ?? -1
|
||||||
|
let fixIsCoarse = accuracy <= 0 || accuracy > roadDistanceCoarseFixAccuracyMeters
|
||||||
|
guard (fixIsCoarse || candidateOffset <= 1_000),
|
||||||
|
candidateOffset <= roadDistanceCoarseFixRadiusMeters,
|
||||||
|
let appFix = loadLocationWithDate(),
|
||||||
|
haversineMetres(cache.sourceLat, cache.sourceLng,
|
||||||
|
appFix.coordinate.lat, appFix.coordinate.lng) <= roadDistanceOriginToleranceMeters,
|
||||||
|
// The stored fix must belong to this cache, not predate it by hours.
|
||||||
|
appFix.date.timeIntervalSince1970 >= cache.updatedAt - 30 * 60 else { return straightLine }
|
||||||
|
|
||||||
|
return DistanceDisplayOrigin(lat: appFix.coordinate.lat, lng: appFix.coordinate.lng,
|
||||||
|
routed: true, source: "appFix")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metres between two coordinates — the same great-circle measure the
|
||||||
|
/// origin/anchor tests use, exposed so a surface can report how far its fix
|
||||||
|
/// sits from the anchor (diagnostics only).
|
||||||
|
static func distanceMetersBetween(lat1: Double, lng1: Double,
|
||||||
|
lat2: Double, lng2: Double) -> Double {
|
||||||
|
haversineMetres(lat1, lng1, lat2, lng2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func haversineMetres(_ 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory copy of the road-distance cache.
|
||||||
|
///
|
||||||
|
/// `loadRoadDistances()` reads the KEYCHAIN — a synchronous `securityd` XPC
|
||||||
|
/// round-trip plus a full base64+JSON decode — and `displayDistanceKM` is
|
||||||
|
/// called PER STATION inside comparisons (`min(by:)`, `sorted`). Re-reading
|
||||||
|
/// it on every call meant tens of thousands of synchronous XPC round-trips
|
||||||
|
/// on the main thread during scene creation, which stalled past the 20 s iOS
|
||||||
|
/// launch watchdog and got the app SIGKILLed (0x8BADF00D). Decode once per
|
||||||
|
/// process, reuse, and refresh on save.
|
||||||
|
private static var roadDistanceMemo: RoadDistanceCache?
|
||||||
|
/// Distinguishes "not loaded yet" from "loaded, and there is no cache".
|
||||||
|
private static var roadDistanceMemoLoaded = false
|
||||||
|
static func saveRoadDistances(sourceLat: Double, sourceLng: Double,
|
||||||
|
entries: [String: CachedRoadDistance],
|
||||||
|
complete: Bool = true) {
|
||||||
let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng,
|
let cache = RoadDistanceCache(sourceLat: sourceLat, sourceLng: sourceLng,
|
||||||
updatedAt: Date().timeIntervalSince1970, entries: entries)
|
updatedAt: Date().timeIntervalSince1970, entries: entries,
|
||||||
|
complete: complete)
|
||||||
if let data = try? JSONEncoder().encode(cache) {
|
if let data = try? JSONEncoder().encode(cache) {
|
||||||
saveString(data.base64EncodedString(), service: roadDistancesKey)
|
saveString(data.base64EncodedString(), service: roadDistancesKey)
|
||||||
}
|
}
|
||||||
|
roadDistanceMemo = cache
|
||||||
|
roadDistanceMemoLoaded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
static func loadRoadDistances() -> RoadDistanceCache? {
|
static func loadRoadDistances() -> RoadDistanceCache? {
|
||||||
guard let raw = loadString(service: roadDistancesKey),
|
if roadDistanceMemoLoaded { return roadDistanceMemo }
|
||||||
|
let decoded: RoadDistanceCache?
|
||||||
|
if let raw = loadString(service: roadDistancesKey),
|
||||||
let data = Data(base64Encoded: raw),
|
let data = Data(base64Encoded: raw),
|
||||||
let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data)
|
let cache = try? JSONDecoder().decode(RoadDistanceCache.self, from: data) {
|
||||||
else { return nil }
|
decoded = cache
|
||||||
return cache
|
} else {
|
||||||
|
decoded = nil
|
||||||
|
}
|
||||||
|
roadDistanceMemo = decoded
|
||||||
|
roadDistanceMemoLoaded = true
|
||||||
|
return decoded
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cached road distance (metres) to a station from the user's location, or
|
/// Cached road distance (metres) to a station from the user's location, or
|
||||||
@@ -1095,6 +1537,49 @@ struct FuelStore {
|
|||||||
/// is now / the station's own pin doesn't match the coordinate that was
|
/// is now / the station's own pin doesn't match the coordinate that was
|
||||||
/// routed.
|
/// routed.
|
||||||
static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
|
static func roadDistanceMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
|
||||||
|
roadDistanceEntry(for: station, userLat: userLat, userLng: userLng)?.meters
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete out-and-back routed journey distance (metres). New caches store
|
||||||
|
/// the return route separately because Apple Maps may choose a different
|
||||||
|
/// return path; legacy one-way caches safely fall back to doubling outward.
|
||||||
|
static func roadJourneyMeters(for station: FuelStation, userLat: Double, userLng: Double) -> Double? {
|
||||||
|
guard let entry = roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) else { return nil }
|
||||||
|
return entry.meters + (entry.returnMeters ?? entry.meters)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a usable routed distance is already cached for this station from
|
||||||
|
/// (near enough) this origin. The routing pass uses it to spend its per-pass
|
||||||
|
/// budget on stations that still need a route instead of re-routing the
|
||||||
|
/// same nearest handful forever.
|
||||||
|
static func hasRoadDistance(for station: FuelStation, userLat: Double, userLng: Double) -> Bool {
|
||||||
|
roadDistanceEntry(for: station, userLat: userLat, userLng: userLng) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True only for current-format route cache entries with a separately
|
||||||
|
/// measured return route. Legacy one-way entries are still usable for journey
|
||||||
|
/// arithmetic (`outward × 2`) but Best Value must describe them as estimated.
|
||||||
|
static func hasMeasuredReturnRoadDistance(for station: FuelStation, userLat: Double, userLng: Double) -> Bool {
|
||||||
|
roadDistanceEntry(for: station, userLat: userLat, userLng: userLng)?.returnMeters != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func roadDistanceDiagnostics(for station: FuelStation, userLat: Double, userLng: Double) -> RoadDistanceDiagnostics {
|
||||||
|
let displayKM = displayDistanceKM(station: station, userLat: userLat, userLng: userLng)
|
||||||
|
let entry = roadDistanceEntry(for: station, userLat: userLat, userLng: userLng)
|
||||||
|
let outward = entry?.meters
|
||||||
|
let returnMeters = entry?.returnMeters
|
||||||
|
return RoadDistanceDiagnostics(
|
||||||
|
displayDistanceKM: displayKM,
|
||||||
|
displaySource: outward == nil ? .straightLine : .roadRoute,
|
||||||
|
outwardMeters: outward,
|
||||||
|
returnMeters: returnMeters,
|
||||||
|
totalJourneyMeters: outward.map { $0 + (returnMeters ?? $0) },
|
||||||
|
hasMeasuredReturn: returnMeters != nil,
|
||||||
|
hasValidCacheEntry: entry != nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func roadDistanceEntry(for station: FuelStation, userLat: Double, userLng: Double) -> CachedRoadDistance? {
|
||||||
guard let cache = loadRoadDistances(),
|
guard let cache = loadRoadDistances(),
|
||||||
let entry = cache.entries[station.id] else { return nil }
|
let entry = cache.entries[station.id] else { return nil }
|
||||||
// The cache is only valid near the location it was built from.
|
// The cache is only valid near the location it was built from.
|
||||||
@@ -1111,16 +1596,42 @@ struct FuelStore {
|
|||||||
// live / cached station sets from injecting each other's road metres.
|
// live / cached station sets from injecting each other's road metres.
|
||||||
guard abs(entry.lat - station.lat) <= roadDistancePinToleranceDegrees,
|
guard abs(entry.lat - station.lat) <= roadDistancePinToleranceDegrees,
|
||||||
abs(entry.lng - station.lng) <= roadDistancePinToleranceDegrees else { return nil }
|
abs(entry.lng - station.lng) <= roadDistancePinToleranceDegrees else { return nil }
|
||||||
return entry.meters
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Distance (km) to display for a station: cached ROAD distance when
|
/// Distance (km) to display for a station: cached ROAD distance when
|
||||||
/// available (matches Apple Maps), else straight-line haversine.
|
/// available (matches Apple Maps), else straight-line haversine.
|
||||||
static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double {
|
static func displayDistanceKM(station: FuelStation, userLat: Double, userLng: Double) -> Double {
|
||||||
if let meters = roadDistanceMeters(for: station, userLat: userLat, userLng: userLng) {
|
stationDistanceDisplay(station: station, userLat: userLat, userLng: userLng).km
|
||||||
return meters / 1000.0
|
|
||||||
}
|
}
|
||||||
return station.distanceKM(to: userLat, lng2: userLng)
|
|
||||||
|
/// Distance to show for a station AND whether it came from a routed road
|
||||||
|
/// distance or the straight-line fallback. Both come out of ONE cache read so
|
||||||
|
/// a surface can never mark a figure "Approx." while showing a routed value
|
||||||
|
/// (or the reverse) — the failure mode when the number and its label are
|
||||||
|
/// computed separately.
|
||||||
|
static func stationDistanceDisplay(station: FuelStation, userLat: Double, userLng: Double) -> StationDistanceDisplay {
|
||||||
|
// Accurate-distances mode: a routed value is served ONLY when the whole
|
||||||
|
// search area has been routed from a nearby anchor. Otherwise every row
|
||||||
|
// falls back to straight-line together, so a list never mixes bases and
|
||||||
|
// the radius filter (which reads this same function) always agrees with
|
||||||
|
// what the rows show.
|
||||||
|
if accurateDistancesEnabled,
|
||||||
|
accurateDistancesReady(userLat: userLat, userLng: userLng),
|
||||||
|
let meters = roadDistanceMeters(for: station, userLat: userLat, userLng: userLng) {
|
||||||
|
return StationDistanceDisplay(km: meters / 1000.0, isApproximate: false)
|
||||||
|
}
|
||||||
|
return StationDistanceDisplay(km: station.distanceKM(to: userLat, lng2: userLng), isApproximate: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Radius test on the SAME distance the UI shows. Filtering straight-line
|
||||||
|
/// while rows display a cached ROAD distance let a station read "14.6 mi"
|
||||||
|
/// inside a list set to "within 10 miles" — both numbers were individually
|
||||||
|
/// right (9.8 mi straight-line, 14.6 mi by road) but the list looked broken.
|
||||||
|
/// Uncached stations still test straight-line, i.e. their displayed value, so
|
||||||
|
/// nothing changes for them.
|
||||||
|
static func isWithinDisplayRadius(station: FuelStation, radiusKM: Double, userLat: Double, userLng: Double) -> Bool {
|
||||||
|
stationDistanceDisplay(station: station, userLat: userLat, userLng: userLng).km <= radiusKM
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Low-level keychain helpers
|
// MARK: Low-level keychain helpers
|
||||||
@@ -1183,6 +1694,53 @@ struct FuelStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lightweight description of the distance value shown for a station and any
|
||||||
|
/// usable road-route cache behind it. No user coordinates are included.
|
||||||
|
struct RoadDistanceDiagnostics: Equatable {
|
||||||
|
let displayDistanceKM: Double
|
||||||
|
let displaySource: FuelSavingsDistanceBasis
|
||||||
|
let outwardMeters: Double?
|
||||||
|
let returnMeters: Double?
|
||||||
|
let totalJourneyMeters: Double?
|
||||||
|
let hasMeasuredReturn: Bool
|
||||||
|
let hasValidCacheEntry: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The station distance a surface should show, plus whether it came from a routed
|
||||||
|
/// road distance or the straight-line fallback. The confidence travels WITH the
|
||||||
|
/// value, so a label can never claim a confidence the figure doesn't have.
|
||||||
|
struct StationDistanceDisplay: Equatable {
|
||||||
|
let km: Double
|
||||||
|
let isApproximate: Bool
|
||||||
|
|
||||||
|
/// Row/sheet label: a straight-line fallback is marked "Approx." so a figure
|
||||||
|
/// that is NOT a routed road distance can never be read as one, and a cached
|
||||||
|
/// road distance stays a bare figure. It updates by itself once routing lands,
|
||||||
|
/// because `FuelStore.stationDistanceDisplay` re-reads the cache every call.
|
||||||
|
func label(unit: DistanceUnit) -> String {
|
||||||
|
let text = unit.format(km)
|
||||||
|
return isApproximate ? "Approx. \(text)" : text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Row/sheet label that keeps the one-way station distance primary, then
|
||||||
|
/// adds the out-and-back distance in brackets. This matches Best Value's
|
||||||
|
/// "extra return travel" basis without changing the actual distance model.
|
||||||
|
func labelWithReturn(unit: DistanceUnit) -> String {
|
||||||
|
"\(label(unit: unit)) (\(unit.format(km * 2)) return)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VoiceOver form of `label(unit:)`.
|
||||||
|
func spokenLabel(unit: DistanceUnit) -> String {
|
||||||
|
let text = unit.spoken(km)
|
||||||
|
return isApproximate ? "Approximately \(text)" : text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VoiceOver form of `labelWithReturn(unit:)`.
|
||||||
|
func spokenLabelWithReturn(unit: DistanceUnit) -> String {
|
||||||
|
"\(spokenLabel(unit: unit)), \(unit.spoken(km * 2)) return"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Cached Apple-Maps road distances for nearby stations (see
|
/// Cached Apple-Maps road distances for nearby stations (see
|
||||||
/// A single cached road distance plus the station pin it was routed to. Keeping
|
/// A single cached road distance plus the station pin it was routed to. Keeping
|
||||||
/// the pin lets `roadDistanceMeters` refuse to serve a route computed for a
|
/// the pin lets `roadDistanceMeters` refuse to serve a route computed for a
|
||||||
@@ -1192,6 +1750,14 @@ struct CachedRoadDistance: Codable {
|
|||||||
let meters: Double
|
let meters: Double
|
||||||
let lat: Double
|
let lat: Double
|
||||||
let lng: Double
|
let lng: Double
|
||||||
|
let returnMeters: Double?
|
||||||
|
|
||||||
|
init(meters: Double, lat: Double, lng: Double, returnMeters: Double? = nil) {
|
||||||
|
self.meters = meters
|
||||||
|
self.lat = lat
|
||||||
|
self.lng = lng
|
||||||
|
self.returnMeters = returnMeters
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres +
|
/// `FuelStore.roadDistancesKey`). `entries` maps stationID → road metres +
|
||||||
@@ -1201,4 +1767,19 @@ struct RoadDistanceCache: Codable {
|
|||||||
let sourceLng: Double
|
let sourceLng: Double
|
||||||
let updatedAt: TimeInterval
|
let updatedAt: TimeInterval
|
||||||
let entries: [String: CachedRoadDistance]
|
let entries: [String: CachedRoadDistance]
|
||||||
|
/// True when `entries` covers EVERY station within
|
||||||
|
/// `RoadDistanceService.maxRadiusKM` of the source — only then may the browse
|
||||||
|
/// layer serve routed distances (see `FuelStore.accurateDistancesReady`).
|
||||||
|
/// Absent in caches written before the accurate-distances toggle existed,
|
||||||
|
/// which were 12-per-pass sets and never complete: absent = not complete.
|
||||||
|
let complete: Bool?
|
||||||
|
|
||||||
|
init(sourceLat: Double, sourceLng: Double, updatedAt: TimeInterval,
|
||||||
|
entries: [String: CachedRoadDistance], complete: Bool? = nil) {
|
||||||
|
self.sourceLat = sourceLat
|
||||||
|
self.sourceLng = sourceLng
|
||||||
|
self.updatedAt = updatedAt
|
||||||
|
self.entries = entries
|
||||||
|
self.complete = complete
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,16 @@
|
|||||||
// MirrorFuelProvider.swift — GitHub price-mirror provider (P0 live chain).
|
// MirrorFuelProvider.swift — GitHub price-mirror provider (P0 live chain).
|
||||||
//
|
//
|
||||||
// The mirror (aptonline/fuelboard-data, pushed daily by the LAN relay) is the
|
// The mirror (aptonline/fuelboard-data, pushed daily by the ingestion relay)
|
||||||
// PRODUCTION data source: its day files are the raw relay /api/v1/stations
|
// is the PRODUCTION data source: its day files use the same /api/v1/stations
|
||||||
// envelope, so the shared decode works verbatim. Fetching from
|
// envelope, so the shared decoder works verbatim. Fetching from
|
||||||
// raw.githubusercontent.com makes live prices work fully off-LAN — App
|
// raw.githubusercontent.com makes live prices work fully off-LAN — App Review
|
||||||
// Review cannot reach a LAN relay, and consumers never have one to reach —
|
// cannot reach a private LAN relay, and consumers never have one to reach —
|
||||||
// and the bundled dump (app target) is the no-network last resort.
|
// with the bundled dump (app target) as the no-network last resort.
|
||||||
//
|
//
|
||||||
// Chain (LiveChainProvider) — GitHub first, EVERYWHERE:
|
// Chain (LiveChainProvider) — public HTTPS only:
|
||||||
// GitHub mirror → [dev-only LAN relay] → (app target: bundled dump in the
|
// GitHub mirror → (app target: bundled dump in the ContentView catch,
|
||||||
// ContentView catch, widget: cache → placeholder)
|
// widget: cache → placeholder)
|
||||||
// The relay is NOT part of the consumer chain at all: it is unreachable from
|
// No local-network fallback or private IP is compiled into the app binary.
|
||||||
// any phone outside the developer's LAN, and even attempting it from a
|
|
||||||
// consumer phone would fire the iOS Local Network permission prompt for an
|
|
||||||
// address that can never be reached. It remains reachable only behind the
|
|
||||||
// hidden dev flag `fuelboard.relayFallback` (Settings → Debug) for home
|
|
||||||
// testing. The relay keeps its server-side job (GOV.UK ingest + daily mirror
|
|
||||||
// push) unchanged.
|
|
||||||
//
|
|
||||||
// Telemetry (FuelBeacon): raw GitHub fetches are invisible to repo analytics
|
|
||||||
// (page views/clones only), so every chain fetch opportunistically pings the
|
|
||||||
// relay's existing widget-diag route (zero relay changes) — but ONLY when the
|
|
||||||
// same dev flag is on, so consumers never make a local-network attempt.
|
|
||||||
// /stats app-hit spikes (dev flag on) = GitHub path failing → fallback active.
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@@ -37,10 +25,11 @@ struct MirrorFuelProvider: FuelPriceProviding {
|
|||||||
/// surfaced via `LiveChainProvider.latestMeta` in Settings → About.
|
/// surfaced via `LiveChainProvider.latestMeta` in Settings → About.
|
||||||
static var latestMeta: RelayMeta?
|
static var latestMeta: RelayMeta?
|
||||||
|
|
||||||
/// App-group cache of the last fetched FULL dump, keyed by snapshot day.
|
/// App-group file cache of the last fetched FULL dump, keyed by snapshot day.
|
||||||
/// The mirror pushes once/day, so between pushes the app reuses the
|
/// The mirror pushes once/day, so between pushes the app reuses the
|
||||||
/// cached dump instead of re-downloading ~2.8 MB at every 12 h gate.
|
/// cached dump instead of re-downloading ~2.8 MB at every 12 h gate.
|
||||||
static let liveDumpCacheKey = "fuelboard.liveDumpCache"
|
static let liveDumpCacheKey = "fuelboard.liveDumpCache"
|
||||||
|
static let liveDumpCacheFileName = "fuelboard.liveDumpCache.json"
|
||||||
|
|
||||||
struct DumpCache: Codable {
|
struct DumpCache: Codable {
|
||||||
let day: String
|
let day: String
|
||||||
@@ -48,18 +37,26 @@ struct MirrorFuelProvider: FuelPriceProviding {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func loadDumpCache() -> DumpCache? {
|
static func loadDumpCache() -> DumpCache? {
|
||||||
|
if let raw = FuelStore.loadAppGroupFile(liveDumpCacheFileName),
|
||||||
|
let cache = try? JSONDecoder().decode(DumpCache.self, from: raw) {
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate the legacy UserDefaults cache when present.
|
||||||
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
|
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
|
||||||
let raw = defaults.data(forKey: liveDumpCacheKey),
|
let raw = defaults.data(forKey: liveDumpCacheKey),
|
||||||
let cache = try? JSONDecoder().decode(DumpCache.self, from: raw) else {
|
let cache = try? JSONDecoder().decode(DumpCache.self, from: raw) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if FuelStore.saveAppGroupFile(raw, name: liveDumpCacheFileName) {
|
||||||
|
defaults.removeObject(forKey: liveDumpCacheKey)
|
||||||
|
}
|
||||||
return cache
|
return cache
|
||||||
}
|
}
|
||||||
|
|
||||||
static func saveDumpCache(day: String, data: Data) {
|
static func saveDumpCache(day: String, data: Data) {
|
||||||
guard let defaults = UserDefaults(suiteName: FuelStore.appGroupSuite),
|
guard let raw = try? JSONEncoder().encode(DumpCache(day: day, data: data)) else { return }
|
||||||
let raw = try? JSONEncoder().encode(DumpCache(day: day, data: data)) else { return }
|
_ = FuelStore.saveAppGroupFile(raw, name: liveDumpCacheFileName)
|
||||||
defaults.set(raw, forKey: liveDumpCacheKey)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when the cached dump is already the freshest the mirror has —
|
/// True when the cached dump is already the freshest the mirror has —
|
||||||
@@ -84,6 +81,14 @@ struct MirrorFuelProvider: FuelPriceProviding {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func cachedStations(near lat: Double? = nil, lng: Double? = nil,
|
||||||
|
fuel: FuelType = .e10, radiusKM: Double? = nil) -> [FuelStation]? {
|
||||||
|
guard let cache = loadDumpCache(),
|
||||||
|
let stations = try? FuelPriceProvider.decodeStations(from: cache.data) else { return nil }
|
||||||
|
latestMeta = FuelPriceProvider.decodeRelayMeta(from: cache.data)
|
||||||
|
return focused(stations, near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
||||||
|
}
|
||||||
|
|
||||||
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
||||||
// 1. Tiny pointer fetch — the freshest snapshot day.
|
// 1. Tiny pointer fetch — the freshest snapshot day.
|
||||||
guard let latest = await FuelHistoryStore.fetchLatest(base: baseURL),
|
guard let latest = await FuelHistoryStore.fetchLatest(base: baseURL),
|
||||||
@@ -107,90 +112,40 @@ struct MirrorFuelProvider: FuelPriceProviding {
|
|||||||
throw FuelProviderError.mirrorUnavailable
|
throw FuelProviderError.mirrorUnavailable
|
||||||
}
|
}
|
||||||
Self.saveDumpCache(day: day, data: fetched)
|
Self.saveDumpCache(day: day, data: fetched)
|
||||||
|
#if DEBUG
|
||||||
|
print("FB-STORAGE dump cached day=\(day) bytes=\(fetched.count) reuse=\(Self.canReuseCache(cachedDay: Self.loadDumpCache()?.day, latestDay: day))")
|
||||||
|
#endif
|
||||||
data = fetched
|
data = fetched
|
||||||
}
|
}
|
||||||
let stations = try FuelPriceProvider.decodeStations(from: data)
|
let stations = try FuelPriceProvider.decodeStations(from: data)
|
||||||
Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data)
|
Self.latestMeta = FuelPriceProvider.decodeRelayMeta(from: data)
|
||||||
|
#if DEBUG
|
||||||
|
print("FB-MIRROR day=\(day) bytes=\(data.count) decoded=\(stations.count)")
|
||||||
|
#endif
|
||||||
return Self.focused(stations, near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
return Self.focused(stations, near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Live chain provider
|
// MARK: - Live chain provider
|
||||||
|
|
||||||
/// The live price chain — GitHub first, everywhere. The LAN relay is NOT part
|
/// The live price chain — public GitHub mirror only. No private LAN fallback is
|
||||||
/// of the consumer chain: consumers can never reach it (and must never even
|
/// compiled into the app binary, so App Store/TestFlight builds cannot trigger
|
||||||
/// attempt it — that would prompt for Local Network access on a stranger's
|
/// Local Network permission or leak a private IP.
|
||||||
/// phone). It only joins behind the hidden dev flag `fuelboard.relayFallback`
|
|
||||||
/// (Settings → Debug) for the developer's own home testing. Whichever leg
|
|
||||||
/// serves is recorded for the About section + telemetry.
|
|
||||||
struct LiveChainProvider: FuelPriceProviding {
|
struct LiveChainProvider: FuelPriceProviding {
|
||||||
var mirror: FuelPriceProviding
|
var mirror: FuelPriceProviding
|
||||||
var relay: FuelPriceProviding
|
|
||||||
|
|
||||||
/// Which leg served the last fetch — Settings → About metadata + beacon.
|
/// Metadata surfaced in Settings → About.
|
||||||
static var latestMeta: RelayMeta?
|
static var latestMeta: RelayMeta?
|
||||||
static var lastSource: String?
|
static var lastSource: String?
|
||||||
|
|
||||||
init(mirror: FuelPriceProviding = MirrorFuelProvider(),
|
init(mirror: FuelPriceProviding = MirrorFuelProvider()) {
|
||||||
relay: FuelPriceProviding = RelayFuelProvider()) {
|
|
||||||
self.mirror = mirror
|
self.mirror = mirror
|
||||||
self.relay = relay
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
func fetchStations(near lat: Double?, lng: Double?, fuel: FuelType, radiusKM: Double?) async throws -> [FuelStation] {
|
||||||
do {
|
|
||||||
let stations = try await mirror.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
let stations = try await mirror.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
||||||
Self.latestMeta = MirrorFuelProvider.latestMeta
|
Self.latestMeta = MirrorFuelProvider.latestMeta
|
||||||
Self.lastSource = "github"
|
Self.lastSource = "github"
|
||||||
FuelBeacon.fire(source: "github", n: stations.count)
|
|
||||||
return stations
|
|
||||||
} catch {
|
|
||||||
// Dev-only fallback: consumers never reach this branch — the flag
|
|
||||||
// is off by default and the flag itself lives in the Debug section.
|
|
||||||
guard FuelStore.loadRelayFallbackEnabled() else { throw error }
|
|
||||||
let stations = try await relay.fetchStations(near: lat, lng: lng, fuel: fuel, radiusKM: radiusKM)
|
|
||||||
Self.latestMeta = RelayFuelProvider.latestMeta
|
|
||||||
Self.lastSource = "relay"
|
|
||||||
FuelBeacon.fire(source: "relay", n: stations.count)
|
|
||||||
return stations
|
return stations
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Telemetry beacon
|
|
||||||
|
|
||||||
/// Fire-and-forget attribution for the LIVE chain (see header note). Never
|
|
||||||
/// awaited, never user-visible; a failed ping is silent. Fires ONLY when the
|
|
||||||
/// dev relay flag is on — consumers must never make a local-network attempt,
|
|
||||||
/// and an off-LAN ping failing silently is itself the reachability datum.
|
|
||||||
/// Disabled in unit tests via `isEnabled`.
|
|
||||||
enum FuelBeacon {
|
|
||||||
static var isEnabled = true
|
|
||||||
|
|
||||||
static var shouldFire: Bool {
|
|
||||||
isEnabled && FuelStore.loadRelayFallbackEnabled()
|
|
||||||
}
|
|
||||||
|
|
||||||
static func fire(source: String, n: Int, timeout: TimeInterval = 2) {
|
|
||||||
guard shouldFire,
|
|
||||||
let url = beaconURL(source: source, n: n) else { return }
|
|
||||||
var request = RelayFuelProvider.relayRequest(url, client: "app", timeout: timeout)
|
|
||||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
|
||||||
Task { _ = try? await URLSession.shared.data(for: request) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Testable URL construction — hits the relay's existing widget-diag
|
|
||||||
/// beacon route with app attribution (zero relay changes).
|
|
||||||
static func beaconURL(source: String, n: Int, base: URL = RelayFuelProvider().baseURL) -> URL? {
|
|
||||||
guard var components = URLComponents(
|
|
||||||
url: base.appendingPathComponent("api/v1/widget-diag"),
|
|
||||||
resolvingAgainstBaseURL: false
|
|
||||||
) else { return nil }
|
|
||||||
components.queryItems = [
|
|
||||||
URLQueryItem(name: "intent", value: "app-live"),
|
|
||||||
URLQueryItem(name: "source", value: source),
|
|
||||||
URLQueryItem(name: "n", value: String(n)),
|
|
||||||
]
|
|
||||||
return components.url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -22,8 +22,14 @@ enum SiriCheapestLookup {
|
|||||||
withinMiles: Double
|
withinMiles: Double
|
||||||
) -> FuelStation? {
|
) -> FuelStation? {
|
||||||
let radiusKM = withinMiles * 1.60934
|
let radiusKM = withinMiles * 1.60934
|
||||||
|
// Same radius rule as the app list: the distance the answer would SHOW
|
||||||
|
// (cached road route where there is one).
|
||||||
return stations
|
return stations
|
||||||
.filter { $0.prices[fuel] != nil && $0.distanceKM(to: lat, lng2: lng) <= radiusKM }
|
.filter {
|
||||||
|
$0.prices[fuel] != nil
|
||||||
|
&& FuelStore.isWithinDisplayRadius(station: $0, radiusKM: radiusKM,
|
||||||
|
userLat: lat, userLng: lng)
|
||||||
|
}
|
||||||
.min { lhs, rhs in
|
.min { lhs, rhs in
|
||||||
guard let lp = lhs.prices[fuel], let rp = rhs.prices[fuel] else {
|
guard let lp = lhs.prices[fuel], let rp = rhs.prices[fuel] else {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// How a station's address should read underneath its name.
|
||||||
|
///
|
||||||
|
/// The official Fuel Finder `address` field is not a street address on its own.
|
||||||
|
/// It normally opens with the station's own name — 14% of stations start with
|
||||||
|
/// the exact name, and many more repeat it in the first segment ("CHIDSWELL
|
||||||
|
/// SERVICE STATION, LEEDS ROAD, DEWSBURY, WF12 7QW" for "Jet Chidswell") — and
|
||||||
|
/// 13% end with the postcode the row appends again. With the name already in the
|
||||||
|
/// row's header (and in the detail sheet's "Name" row), both repetitions read as
|
||||||
|
/// the name stated twice.
|
||||||
|
///
|
||||||
|
/// The rules are deliberately conservative: a leading segment carrying a house
|
||||||
|
/// number is a real street address and is left alone, and a partial-word match
|
||||||
|
/// ("Jet" against "JETSON FILLING STATION") never cuts into a word.
|
||||||
|
enum StationAddressLine {
|
||||||
|
/// Words that carry no identity, so a leading segment made only of these
|
||||||
|
/// plus words taken from the name still counts as the name.
|
||||||
|
private static let filler: Set<String> = [
|
||||||
|
"service", "services", "station", "stations", "filling", "petrol",
|
||||||
|
"garage", "motors", "motor", "centre", "center", "store", "superstore",
|
||||||
|
"ltd", "limited", "trading", "the", "and", "of", "at",
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The line to display: any leading repeat of the name removed, and the
|
||||||
|
/// postcode written exactly once (whatever the address already carries).
|
||||||
|
static func display(address: String, postcode: String, name: String) -> String {
|
||||||
|
let postcodeText = postcode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
var text = collapsingSpaces(address)
|
||||||
|
if text.isEmpty { return postcodeText }
|
||||||
|
text = removingLeadingName(from: text, name: name)
|
||||||
|
text = removingTrailingPostcode(from: text, postcode: postcodeText)
|
||||||
|
text = text.trimmingCharacters(in: CharacterSet(charactersIn: " ,;-"))
|
||||||
|
if text.isEmpty { return postcodeText }
|
||||||
|
if postcodeText.isEmpty { return text }
|
||||||
|
return "\(text), \(postcodeText)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private static func collapsingSpaces(_ text: String) -> String {
|
||||||
|
text.split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" })
|
||||||
|
.joined(separator: " ")
|
||||||
|
.trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Letters and digits only, lowercased — so punctuation and spacing cannot
|
||||||
|
/// make two spellings of the same name look different.
|
||||||
|
private static func key(_ text: String) -> String {
|
||||||
|
text.lowercased().filter { $0.isLetter || $0.isNumber }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func removingLeadingName(from text: String, name: String) -> String {
|
||||||
|
let nameKey = key(name)
|
||||||
|
guard !nameKey.isEmpty else { return text }
|
||||||
|
if key(text).hasPrefix(nameKey), endsAtWordBoundary(text, characters: nameKey.count) {
|
||||||
|
return droppingFirst(text, characters: nameKey.count)
|
||||||
|
}
|
||||||
|
return removingLeadingNameSegment(from: text, nameKey: nameKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the first `count` letters/digits of `text` are followed by
|
||||||
|
/// something that is not part of a word — a partial-word match must never
|
||||||
|
/// cut a station out of the middle of a word.
|
||||||
|
private static func endsAtWordBoundary(_ text: String, characters count: Int) -> Bool {
|
||||||
|
var seen = 0
|
||||||
|
var index = text.startIndex
|
||||||
|
while index < text.endIndex, seen < count {
|
||||||
|
let character = text[index]
|
||||||
|
if character.isLetter || character.isNumber { seen += 1 }
|
||||||
|
index = text.index(after: index)
|
||||||
|
}
|
||||||
|
guard index < text.endIndex else { return true }
|
||||||
|
let next = text[index]
|
||||||
|
return !(next.isLetter || next.isNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops the first `count` letters/digits of `text` and any separator after
|
||||||
|
/// them, leaving the rest of the original spelling untouched.
|
||||||
|
private static func droppingFirst(_ text: String, characters count: Int) -> String {
|
||||||
|
var seen = 0
|
||||||
|
var index = text.startIndex
|
||||||
|
while index < text.endIndex, seen < count {
|
||||||
|
let character = text[index]
|
||||||
|
if character.isLetter || character.isNumber { seen += 1 }
|
||||||
|
index = text.index(after: index)
|
||||||
|
}
|
||||||
|
return String(text[index...]).trimmingCharacters(in: CharacterSet(charactersIn: " ,;-"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops a trailing repeat of the postcode.
|
||||||
|
private static func removingTrailingPostcode(from text: String, postcode: String) -> String {
|
||||||
|
let postcodeKey = key(postcode)
|
||||||
|
guard !postcodeKey.isEmpty else { return text }
|
||||||
|
let textKey = key(text)
|
||||||
|
guard textKey.hasSuffix(postcodeKey), textKey.count > postcodeKey.count else { return text }
|
||||||
|
var count = postcodeKey.count
|
||||||
|
var index = text.endIndex
|
||||||
|
while index > text.startIndex, count > 0 {
|
||||||
|
index = text.index(before: index)
|
||||||
|
let character = text[index]
|
||||||
|
if character.isLetter || character.isNumber { count -= 1 }
|
||||||
|
}
|
||||||
|
return String(text[text.startIndex..<index]).trimmingCharacters(in: CharacterSet(charactersIn: " ,;-"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "CHIDSWELL SERVICE STATION, LEEDS ROAD…" holds the name in its first
|
||||||
|
/// segment but never as a literal prefix. Drop that segment only when it
|
||||||
|
/// carries no digits — a house number means it is really a street address —
|
||||||
|
/// and every word in it is either filler or part of the name.
|
||||||
|
private static func removingLeadingNameSegment(from text: String, nameKey: String) -> String {
|
||||||
|
guard let comma = text.firstIndex(of: ",") else { return text }
|
||||||
|
let segment = String(text[text.startIndex..<comma])
|
||||||
|
if segment.contains(where: { $0.isNumber }) { return text }
|
||||||
|
let words = segment.split(whereSeparator: { !$0.isLetter }).map { $0.lowercased() }
|
||||||
|
let significant = words.filter { $0.count >= 3 && !filler.contains($0) }
|
||||||
|
guard !significant.isEmpty, significant.allSatisfy({ nameKey.contains($0) }) else { return text }
|
||||||
|
return String(text[text.index(after: comma)...])
|
||||||
|
.trimmingCharacters(in: CharacterSet(charactersIn: " ,;-"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,11 @@ final class WidgetLocationFetcher: NSObject, CLLocationManagerDelegate {
|
|||||||
|
|
||||||
/// Requests the current location. Returns nil on denial, timeout, or
|
/// Requests the current location. Returns nil on denial, timeout, or
|
||||||
/// no-permission — callers should fall back to cached data.
|
/// no-permission — callers should fall back to cached data.
|
||||||
|
///
|
||||||
|
/// The `CLLocation` is returned intact so callers can read
|
||||||
|
/// `horizontalAccuracy`: the extension is often handed a coarse Wi-Fi/cell
|
||||||
|
/// fix, and a coarse fix must not be mistaken for the user having moved away
|
||||||
|
/// from the app's routed distances (see `FuelStore.distanceDisplayOrigin`).
|
||||||
func currentLocation(timeout: TimeInterval = 4) async -> CLLocation? {
|
func currentLocation(timeout: TimeInterval = 4) async -> CLLocation? {
|
||||||
guard CLLocationManager.locationServicesEnabled() else { return nil }
|
guard CLLocationManager.locationServicesEnabled() else { return nil }
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ cd "$(dirname "$0")/.."
|
|||||||
PROJECT="$PWD/FuelBoard.xcodeproj"
|
PROJECT="$PWD/FuelBoard.xcodeproj"
|
||||||
SCHEME="FuelBoard"
|
SCHEME="FuelBoard"
|
||||||
DERIVED_DATA="/tmp/fuelboard-build-ipas"
|
DERIVED_DATA="/tmp/fuelboard-build-ipas"
|
||||||
SERVE_DIR="/tmp/ipa-serve"
|
SERVE_DIR="$PWD/Builds"
|
||||||
FULL_IPA="$SERVE_DIR/FuelBoard-watch.ipa"
|
FULL_IPA="$SERVE_DIR/FuelBoard-watch.ipa"
|
||||||
SIDELOAD_IPA="$SERVE_DIR/FuelBoard-sideload.ipa"
|
SIDELOAD_IPA="$SERVE_DIR/FuelBoard-sideload.ipa"
|
||||||
HOST="192.168.1.131"
|
HOST="192.168.1.131"
|
||||||
PORT="8765"
|
PORT="8765"
|
||||||
|
|
||||||
|
export SERVE_DIR
|
||||||
|
|
||||||
APP_PATH="$DERIVED_DATA/Build/Products/Release-iphoneos/FuelBoard.app"
|
APP_PATH="$DERIVED_DATA/Build/Products/Release-iphoneos/FuelBoard.app"
|
||||||
STAGE_FULL="/tmp/fuelboard-ipa-stage-full"
|
STAGE_FULL="/tmp/fuelboard-ipa-stage-full"
|
||||||
STAGE_SIDELOAD="/tmp/fuelboard-ipa-stage-sideload"
|
STAGE_SIDELOAD="/tmp/fuelboard-ipa-stage-sideload"
|
||||||
@@ -46,7 +48,9 @@ import os, shutil, zipfile
|
|||||||
app = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
|
app = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
|
||||||
stage = '/tmp/fuelboard-ipa-stage-full'
|
stage = '/tmp/fuelboard-ipa-stage-full'
|
||||||
payload = os.path.join(stage, 'Payload')
|
payload = os.path.join(stage, 'Payload')
|
||||||
out = '/tmp/ipa-serve/FuelBoard-watch.ipa'
|
serve = os.environ['SERVE_DIR']
|
||||||
|
out = os.path.join(serve, 'FuelBoard-watch.ipa')
|
||||||
|
os.makedirs(serve, exist_ok=True)
|
||||||
if os.path.exists(stage):
|
if os.path.exists(stage):
|
||||||
shutil.rmtree(stage)
|
shutil.rmtree(stage)
|
||||||
os.makedirs(payload, exist_ok=True)
|
os.makedirs(payload, exist_ok=True)
|
||||||
@@ -73,7 +77,8 @@ import os, shutil, zipfile
|
|||||||
app = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
|
app = '/tmp/fuelboard-build-ipas/Build/Products/Release-iphoneos/FuelBoard.app'
|
||||||
stage = '/tmp/fuelboard-ipa-stage-sideload'
|
stage = '/tmp/fuelboard-ipa-stage-sideload'
|
||||||
payload = os.path.join(stage, 'Payload')
|
payload = os.path.join(stage, 'Payload')
|
||||||
out = '/tmp/ipa-serve/FuelBoard-sideload.ipa'
|
serve = os.environ['SERVE_DIR']
|
||||||
|
out = os.path.join(serve, 'FuelBoard-sideload.ipa')
|
||||||
if os.path.exists(stage):
|
if os.path.exists(stage):
|
||||||
shutil.rmtree(stage)
|
shutil.rmtree(stage)
|
||||||
os.makedirs(payload, exist_ok=True)
|
os.makedirs(payload, exist_ok=True)
|
||||||
@@ -117,6 +122,7 @@ for name, path in paths.items():
|
|||||||
PY
|
PY
|
||||||
|
|
||||||
echo "==> URLs"
|
echo "==> URLs"
|
||||||
|
echo "Serving: ipa-serve # in $(basename "$PWD"), or: ipa-serve fuelboard"
|
||||||
echo "Watch-capable IPA: http://$HOST:$PORT/FuelBoard-watch.ipa"
|
echo "Watch-capable IPA: http://$HOST:$PORT/FuelBoard-watch.ipa"
|
||||||
echo "Sideload-safe IPA: http://$HOST:$PORT/FuelBoard-sideload.ipa"
|
echo "Sideload-safe IPA: http://$HOST:$PORT/FuelBoard-sideload.ipa"
|
||||||
echo "Xcode log: /tmp/fuelboard-build-ipas-xcodebuild.log"
|
echo "Xcode log: /tmp/fuelboard-build-ipas-xcodebuild.log"
|
||||||
|
|||||||
Executable
+73
@@ -0,0 +1,73 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# install.sh — build FuelBoard dev-signed and install straight to a paired iPhone.
|
||||||
|
#
|
||||||
|
# Streamlined flow (single command):
|
||||||
|
# ./scripts/install.sh # build + install + launch on the first paired iPhone
|
||||||
|
# ./scripts/install.sh --device UDID # target a specific device (xcrun devicectl list devices)
|
||||||
|
# ./scripts/install.sh --no-launch # install but don't auto-launch
|
||||||
|
#
|
||||||
|
# Why this exists: sideloading (SideStore/AltStore) got replaced on 2026-09-25 by a
|
||||||
|
# proper dev-signed install via devicectl — the phone is paired, so no LAN server,
|
||||||
|
# no delete/reinstall dance, and the installed app is a real signed build. The
|
||||||
|
# Release config targets the device (not the simulator) and reuses Xcode automatic
|
||||||
|
# signing with the project's DEVELOPMENT_TEAM.
|
||||||
|
#
|
||||||
|
# Requires: a paired + unlocked iPhone (xcrun devicectl list devices), and the
|
||||||
|
# Apple Development identity in the keychain.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
PROJECT="$PWD/FuelBoard.xcodeproj"
|
||||||
|
SCHEME="FuelBoard"
|
||||||
|
DERIVED_DATA="/tmp/fuelboard-devinstall"
|
||||||
|
APP="$DERIVED_DATA/Build/Products/Release-iphoneos/FuelBoard.app"
|
||||||
|
DEVICE=""
|
||||||
|
DEVICE_NAME="(first paired iPhone)"
|
||||||
|
LAUNCH=1
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--device) DEVICE="$2"; shift 2 ;;
|
||||||
|
--no-launch) LAUNCH=0; shift ;;
|
||||||
|
*) echo "unknown arg: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Resolve the device if not given: the first connected physical iPhone.
|
||||||
|
# devicectl --device wants the CoreDevice Identifier (field 3 of `list devices`,
|
||||||
|
# e.g. C519BE18-...). NOTE: xcodebuild -destination wants the hardware UDID
|
||||||
|
# (00008130-...), so we build with the generic platform destination and only use
|
||||||
|
# the CoreDevice Identifier for devicectl install/launch.
|
||||||
|
if [[ -z "$DEVICE" ]]; then
|
||||||
|
DEVICE="$(xcrun devicectl list devices 2>/dev/null | awk '/(available \(paired\))/ && /iPhone/ {print $3; exit}')"
|
||||||
|
if [[ -z "$DEVICE" ]]; then
|
||||||
|
echo "install.sh: no paired iPhone found. Check: xcrun devicectl list devices" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Build (Release, dev-signed, generic iOS destination)"
|
||||||
|
rm -rf "$DERIVED_DATA"
|
||||||
|
xcodebuild -project "$PROJECT" -scheme "$SCHEME" -configuration Release \
|
||||||
|
-destination 'generic/platform=iOS' \
|
||||||
|
-derivedDataPath "$DERIVED_DATA" \
|
||||||
|
CODE_SIGN_STYLE=Automatic \
|
||||||
|
build 2>&1 | grep -E "error:|BUILD (SUCCEEDED|FAILED)|Signing Identity" | tail -8
|
||||||
|
|
||||||
|
echo "==> Verify bundle"
|
||||||
|
test -d "$APP" || { echo "build failed: no app at $APP" >&2; exit 1; }
|
||||||
|
echo " bundle: $(plutil -extract CFBundleIdentifier raw "$APP/Info.plist")" \
|
||||||
|
" $(plutil -extract CFBundleShortVersionString raw "$APP/Info.plist")" \
|
||||||
|
"($(plutil -extract CFBundleVersion raw "$APP/Info.plist"))"
|
||||||
|
|
||||||
|
echo "==> Install via devicectl"
|
||||||
|
xcrun devicectl device install app --device "$DEVICE" "$APP" 2>&1 | tail -6
|
||||||
|
|
||||||
|
if [[ "$LAUNCH" -eq 1 ]]; then
|
||||||
|
BID="$(plutil -extract CFBundleIdentifier raw "$APP/Info.plist")"
|
||||||
|
echo "==> Launch"
|
||||||
|
xcrun devicectl device process launch --device "$DEVICE" "$BID" 2>&1 | tail -4
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Done ✅ (delete the old icon on the phone if you want it refreshed)"
|
||||||
Reference in New Issue
Block a user